From 2059f9911db8b8b026092694c783c60e83306c40 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:58:46 +0000 Subject: [PATCH 01/15] Read NODE_OPTIONS and apply the flags Bun implements Tokenize NODE_OPTIONS with Node's rules, filter it through Node's kAllowedInEnvvar allowlist, and splice the flags Bun implements into argv after argv[0], normalized to --flag=value tokens. Flags outside the allowlist warn once. Allowed flags Bun does not implement are dropped silently. Injected flags are excluded from process.execArgv and from standalone-executable passthrough argv. --- src/bun_core/env_var.rs | 3 + src/bun_core/lib.rs | 1 + src/bun_core/node_options.rs | 680 ++++++++++++++++++++++++++++++ src/bun_core/util.rs | 31 ++ src/jsc/bindings/BunProcess.cpp | 7 +- src/runtime/cli/mod.rs | 9 +- src/runtime/node/node_process.rs | 5 + test/cli/env/node-options.test.ts | 226 ++++++++++ test/harness.ts | 3 + 9 files changed, 959 insertions(+), 6 deletions(-) create mode 100644 src/bun_core/node_options.rs create mode 100644 test/cli/env/node-options.test.ts diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 5295b888775d..5d165e1d044f 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -166,6 +166,9 @@ new!(pub NODE_DISABLE_COMPILE_CACHE: string, "NODE_DISABLE_COMPILE_CACHE", {}); // child's CLI entrypoint checks this before anything else and hands off to // C++ Bun__WebView__hostMain. Never returns — no JSC, no VM. new!(pub BUN_INTERNAL_WEBVIEW_HOST: string, "BUN_INTERNAL_WEBVIEW_HOST", {}); +// Space-separated CLI flags, tokenized and allowlist-filtered into argv by +// `node_options::filter` (see `util::argv_view_init`). +new!(pub NODE_OPTIONS: string, "NODE_OPTIONS", {}); new!(pub NODE_PENDING_DEPRECATION: string, "NODE_PENDING_DEPRECATION", {}); new!(pub NODE_PRESERVE_SYMLINKS_MAIN: boolean, "NODE_PRESERVE_SYMLINKS_MAIN", { default: false }); new!(pub NODE_USE_SYSTEM_CA: boolean, "NODE_USE_SYSTEM_CA", { default: false }); diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 0301f8ae6eb4..03daf523a1bd 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -245,6 +245,7 @@ pub mod os { pub mod deprecated; pub mod env_var; pub mod feature_flags; +pub mod node_options; /// Tier-0 path-separator predicates. Sunk from `bun_paths` so `bun_core::util` /// (dirname, which) can use them without an upward dep. `bun_paths` re-exports diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs new file mode 100644 index 000000000000..3af3b49e237b --- /dev/null +++ b/src/bun_core/node_options.rs @@ -0,0 +1,680 @@ +//! `NODE_OPTIONS` environment variable parsing (Node.js compatibility). +//! +//! Node.js reads a space-separated list of CLI flags from `NODE_OPTIONS`, +//! tokenizes it (double-quote aware, `\` escape inside quotes), validates each +//! flag against a fixed allowlist, and applies it before the real command-line +//! arguments. [`filter`] mirrors that: it returns the subset of tokens Bun +//! implements, normalized to single `--flag=value` argv tokens, so +//! `argv_view_init` can splice them after `argv[0]` and the regular CLI parser +//! applies them. Allowed-but-unimplemented flags are dropped silently (Node +//! owns them); flags outside Node's allowlist produce a one-shot warning so a +//! misconfigured `NODE_OPTIONS` is surfaced instead of silently ignored. + +#[derive(Debug, PartialEq, Eq)] +pub enum TokenizeError { + UnterminatedString, + InvalidEscape, +} + +/// Tokenize a `NODE_OPTIONS` string using Node.js's rules (see +/// `ParseNodeOptionsEnvVar` in node/src/node_options.cc): space separates +/// arguments, `"` toggles a quoted region, and inside a quoted region `\c` +/// yields `c`. Single quotes and backslashes outside quotes are literal. +pub fn tokenize(input: &[u8]) -> Result>, TokenizeError> { + let mut tokens: Vec> = Vec::new(); + let mut is_in_string = false; + let mut will_start_new_arg = true; + let mut current: Vec = Vec::new(); + let mut i = 0usize; + while i < input.len() { + let mut c = input[i]; + if c == b'\\' && is_in_string { + if i + 1 == input.len() { + return Err(TokenizeError::InvalidEscape); + } + i += 1; + c = input[i]; + } else if c == b' ' && !is_in_string { + will_start_new_arg = true; + i += 1; + continue; + } else if c == b'"' { + is_in_string = !is_in_string; + i += 1; + continue; + } + + if will_start_new_arg { + if !current.is_empty() { + tokens.push(core::mem::take(&mut current).into_boxed_slice()); + } + will_start_new_arg = false; + } + current.push(c); + i += 1; + } + if is_in_string { + return Err(TokenizeError::UnterminatedString); + } + if !current.is_empty() { + tokens.push(current.into_boxed_slice()); + } + Ok(tokens) +} + +/// Split `--name=value` into `(name, Some(value))`; `--name` into +/// `(name, None)`. Short options (`-x`) are returned whole with no value: Node +/// only accepts `-r path` / `-C cond` (space-separated), not `-r=path` or +/// `-rpath`. +fn split_name_value(tok: &[u8]) -> (&[u8], Option<&[u8]>) { + if tok.len() >= 2 && tok[0] == b'-' && tok[1] == b'-' { + if let Some(eq) = crate::strings::index_of_char_usize(tok, b'=') { + return (&tok[..eq], Some(&tok[eq + 1..])); + } + } + (tok, None) +} + +/// Node.js accepts `_` in place of `-` after the leading `--` (V8 convention). +fn normalize(flag: &[u8]) -> Box<[u8]> { + let mut out = Vec::with_capacity(flag.len()); + for (i, &b) in flag.iter().enumerate() { + out.push(if i >= 2 && b == b'_' { b'-' } else { b }); + } + out.into_boxed_slice() +} + +/// Flags Node.js permits in NODE_OPTIONS (the `kAllowedInEnvvar` set). Sorted +/// for binary search. Regenerate from a Node.js build with: +/// node -e '[...process.allowedNodeEnvironmentFlags].sort().forEach(f => console.log(f))' +static ALLOWED: &[&[u8]] = &[ + b"--abort-on-uncaught-exception", + b"--addons", + b"--allow-addons", + b"--allow-child-process", + b"--allow-ffi", + b"--allow-fs-read", + b"--allow-fs-write", + b"--allow-inspector", + b"--allow-net", + b"--allow-wasi", + b"--allow-worker", + b"--async-context-frame", + b"--conditions", + b"--cpu-prof", + b"--cpu-prof-dir", + b"--cpu-prof-interval", + b"--cpu-prof-name", + b"--debug-arraybuffer-allocations", + b"--debug-port", + b"--deprecation", + b"--diagnostic-dir", + b"--disable-proto", + b"--disable-sigusr1", + b"--disable-warning", + b"--disable-wasm-trap-handler", + b"--disallow-code-generation-from-strings", + b"--dns-result-order", + b"--enable-etw-stack-walking", + b"--enable-fips", + b"--enable-network-family-autoselection", + b"--enable-source-maps", + b"--entry-url", + b"--es-module-specifier-resolution", + b"--experimental-abortcontroller", + b"--experimental-addon-modules", + b"--experimental-async-context-frame", + b"--experimental-detect-module", + b"--experimental-eventsource", + b"--experimental-fetch", + b"--experimental-ffi", + b"--experimental-global-customevent", + b"--experimental-global-navigator", + b"--experimental-global-webcrypto", + b"--experimental-import-meta-resolve", + b"--experimental-json-modules", + b"--experimental-loader", + b"--experimental-modules", + b"--experimental-network-imports", + b"--experimental-permission", + b"--experimental-policy", + b"--experimental-print-required-tla", + b"--experimental-quic", + b"--experimental-repl-await", + b"--experimental-report", + b"--experimental-require-module", + b"--experimental-shadow-realm", + b"--experimental-specifier-resolution", + b"--experimental-sqlite", + b"--experimental-stream-iter", + b"--experimental-strip-types", + b"--experimental-test-coverage", + b"--experimental-test-isolation", + b"--experimental-top-level-await", + b"--experimental-transform-types", + b"--experimental-vm-modules", + b"--experimental-wasi-unstable-preview1", + b"--experimental-wasm-modules", + b"--experimental-websocket", + b"--experimental-webstorage", + b"--experimental-worker", + b"--expose-gc", + b"--expose-internals", + b"--extra-info-on-fatal-exception", + b"--force-async-hooks-checks", + b"--force-context-aware", + b"--force-fips", + b"--force-node-api-uncaught-exceptions-policy", + b"--frozen-intrinsics", + b"--global-search-paths", + b"--heap-prof", + b"--heap-prof-dir", + b"--heap-prof-interval", + b"--heap-prof-name", + b"--heapsnapshot-near-heap-limit", + b"--heapsnapshot-signal", + b"--http-parser", + b"--icu-data-dir", + b"--import", + b"--input-type", + b"--insecure-http-parser", + b"--inspect", + b"--inspect-brk", + b"--inspect-port", + b"--inspect-publish-uid", + b"--inspect-wait", + b"--interpreted-frames-native-stack", + b"--jitless", + b"--loader", + b"--localstorage-file", + b"--max-heap-size", + b"--max-http-header-size", + b"--max-old-space-size", + b"--max-old-space-size-percentage", + b"--max-semi-space-size", + b"--napi-modules", + b"--network-family-autoselection", + b"--network-family-autoselection-attempt-timeout", + b"--no-addons", + b"--no-allow-addons", + b"--no-allow-child-process", + b"--no-allow-ffi", + b"--no-allow-inspector", + b"--no-allow-net", + b"--no-allow-wasi", + b"--no-allow-worker", + b"--no-async-context-frame", + b"--no-cpu-prof", + b"--no-debug-arraybuffer-allocations", + b"--no-deprecation", + b"--no-disable-sigusr1", + b"--no-disable-wasm-trap-handler", + b"--no-enable-fips", + b"--no-enable-source-maps", + b"--no-entry-url", + b"--no-experimental-addon-modules", + b"--no-experimental-async-context-frame", + b"--no-experimental-detect-module", + b"--no-experimental-eventsource", + b"--no-experimental-fetch", + b"--no-experimental-ffi", + b"--no-experimental-global-customevent", + b"--no-experimental-global-navigator", + b"--no-experimental-global-webcrypto", + b"--no-experimental-import-meta-resolve", + b"--no-experimental-print-required-tla", + b"--no-experimental-repl-await", + b"--no-experimental-require-module", + b"--no-experimental-shadow-realm", + b"--no-experimental-sqlite", + b"--no-experimental-stream-iter", + b"--no-experimental-strip-types", + b"--no-experimental-transform-types", + b"--no-experimental-vm-modules", + b"--no-experimental-websocket", + b"--no-experimental-webstorage", + b"--no-extra-info-on-fatal-exception", + b"--no-force-async-hooks-checks", + b"--no-force-context-aware", + b"--no-force-fips", + b"--no-force-node-api-uncaught-exceptions-policy", + b"--no-frozen-intrinsics", + b"--no-global-search-paths", + b"--no-heap-prof", + b"--no-insecure-http-parser", + b"--no-inspect", + b"--no-inspect-brk", + b"--no-inspect-wait", + b"--no-network-family-autoselection", + b"--no-node-snapshot", + b"--no-openssl-legacy-provider", + b"--no-openssl-shared-config", + b"--no-pending-deprecation", + b"--no-permission", + b"--no-permission-audit", + b"--no-preserve-symlinks", + b"--no-preserve-symlinks-main", + b"--no-report-compact", + b"--no-report-exclude-env", + b"--no-report-exclude-network", + b"--no-report-on-fatalerror", + b"--no-report-on-signal", + b"--no-report-uncaught-exception", + b"--no-require-module", + b"--no-strip-types", + b"--no-test-only", + b"--no-test-randomize", + b"--no-throw-deprecation", + b"--no-tls-max-v1.2", + b"--no-tls-max-v1.3", + b"--no-tls-min-v1.0", + b"--no-tls-min-v1.1", + b"--no-tls-min-v1.2", + b"--no-tls-min-v1.3", + b"--no-trace-deprecation", + b"--no-trace-env", + b"--no-trace-env-js-stack", + b"--no-trace-env-native-stack", + b"--no-trace-exit", + b"--no-trace-promises", + b"--no-trace-sigint", + b"--no-trace-sync-io", + b"--no-trace-tls", + b"--no-trace-uncaught", + b"--no-trace-warnings", + b"--no-track-heap-objects", + b"--no-use-bundled-ca", + b"--no-use-env-proxy", + b"--no-use-openssl-ca", + b"--no-use-system-ca", + b"--no-verify-base-objects", + b"--no-warnings", + b"--no-watch", + b"--no-watch-preserve-output", + b"--no-zero-fill-buffers", + b"--node-memory-debug", + b"--node-snapshot", + b"--openssl-config", + b"--openssl-legacy-provider", + b"--openssl-shared-config", + b"--pending-deprecation", + b"--perf-basic-prof", + b"--perf-basic-prof-only-functions", + b"--perf-prof", + b"--perf-prof-unwinding-info", + b"--permission", + b"--permission-audit", + b"--policy-integrity", + b"--preserve-symlinks", + b"--preserve-symlinks-main", + b"--prof-process", + b"--redirect-warnings", + b"--report-compact", + b"--report-dir", + b"--report-directory", + b"--report-exclude-env", + b"--report-exclude-network", + b"--report-filename", + b"--report-on-fatalerror", + b"--report-on-signal", + b"--report-signal", + b"--report-uncaught-exception", + b"--require", + b"--require-module", + b"--secure-heap", + b"--secure-heap-min", + b"--snapshot-blob", + b"--stack-trace-limit", + b"--strip-types", + 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-only", + b"--test-random-seed", + b"--test-randomize", + b"--test-reporter", + b"--test-reporter-destination", + b"--test-rerun-failures", + b"--test-shard", + b"--test-skip-pattern", + b"--throw-deprecation", + b"--title", + b"--tls-cipher-list", + b"--tls-keylog", + b"--tls-max-v1.2", + b"--tls-max-v1.3", + b"--tls-min-v1.0", + b"--tls-min-v1.1", + b"--tls-min-v1.2", + b"--tls-min-v1.3", + b"--trace-deprecation", + b"--trace-env", + b"--trace-env-js-stack", + b"--trace-env-native-stack", + b"--trace-event-categories", + b"--trace-event-file-pattern", + b"--trace-events-enabled", + b"--trace-exit", + b"--trace-promises", + b"--trace-require-module", + b"--trace-sigint", + b"--trace-sync-io", + b"--trace-tls", + b"--trace-uncaught", + b"--trace-warnings", + b"--track-heap-objects", + b"--unhandled-rejections", + b"--use-bundled-ca", + b"--use-env-proxy", + b"--use-largepages", + b"--use-openssl-ca", + b"--use-system-ca", + b"--v8-pool-size", + b"--verify-base-objects", + b"--warnings", + b"--watch", + b"--watch-kill-signal", + b"--watch-path", + b"--watch-preserve-output", + b"--webstorage", + b"--zero-fill-buffers", + b"-C", + b"-r", +]; + +#[inline] +fn is_allowed(flag: &[u8]) -> bool { + #[cfg(debug_assertions)] + { + static CHECKED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); + if !CHECKED.swap(true, core::sync::atomic::Ordering::Relaxed) { + for pair in ALLOWED.windows(2) { + debug_assert!(pair[0] < pair[1], "ALLOWED must be sorted"); + } + } + } + ALLOWED.binary_search(&flag).is_ok() +} + +/// 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. +fn is_bun_flag(flag: &[u8]) -> bool { + matches!(flag, b"--bun" | b"-b" | b"--smol" | b"--hot") +} + +/// How a NODE_OPTIONS flag that Bun implements is spliced into argv. +enum Supported { + /// Boolean flag: splice the canonical name alone. + Flag, + /// Requires a value (`--flag=value` or `--flag value`): splice as + /// `--flag=value` so the value can never be mistaken for a subcommand or + /// an entrypoint. + Value, + /// Optional value, only via `=` (Node never attaches the next token): + /// splice the canonical name plus the inline value when present. + OptionalValue, +} + +/// The subset of Node's allowlist that Bun's CLI implements, plus Node's +/// short forms (`-r`, `-C`) mapped to the long spelling Bun declares. +fn supported(flag: &[u8]) -> Option<(&'static [u8], Supported)> { + Some(match flag { + b"--conditions" | b"-C" => (b"--conditions", Supported::Value), + b"--require" | b"-r" => (b"--require", Supported::Value), + b"--import" => (b"--import", Supported::Value), + b"--disable-warning" => (b"--disable-warning", Supported::Value), + b"--dns-result-order" => (b"--dns-result-order", Supported::Value), + b"--max-http-header-size" => (b"--max-http-header-size", Supported::Value), + b"--redirect-warnings" => (b"--redirect-warnings", Supported::Value), + b"--title" => (b"--title", Supported::Value), + b"--unhandled-rejections" => (b"--unhandled-rejections", Supported::Value), + b"--expose-gc" => (b"--expose-gc", Supported::Flag), + b"--no-addons" => (b"--no-addons", Supported::Flag), + b"--no-deprecation" => (b"--no-deprecation", Supported::Flag), + b"--no-warnings" => (b"--no-warnings", Supported::Flag), + b"--pending-deprecation" => (b"--pending-deprecation", Supported::Flag), + b"--preserve-symlinks" => (b"--preserve-symlinks", Supported::Flag), + b"--preserve-symlinks-main" => (b"--preserve-symlinks-main", Supported::Flag), + b"--throw-deprecation" => (b"--throw-deprecation", Supported::Flag), + b"--trace-deprecation" => (b"--trace-deprecation", Supported::Flag), + b"--trace-warnings" => (b"--trace-warnings", Supported::Flag), + b"--use-system-ca" => (b"--use-system-ca", Supported::Flag), + b"--zero-fill-buffers" => (b"--zero-fill-buffers", Supported::Flag), + b"--inspect" => (b"--inspect", Supported::OptionalValue), + b"--inspect-brk" => (b"--inspect-brk", Supported::OptionalValue), + b"--inspect-wait" => (b"--inspect-wait", Supported::OptionalValue), + _ => return None, + }) +} + +#[cold] +#[inline(never)] +fn warn_not_allowed(flag: &[u8]) { + crate::warn!( + "{} is not allowed in NODE_OPTIONS (ignored)", + crate::BStr::new(flag), + ); +} + +#[cold] +#[inline(never)] +fn fail_requires_argument(flag: &[u8]) -> ! { + crate::output::err_generic( + "{} requires an argument", + format_args!("{}", crate::BStr::new(flag)), + ); + crate::Global::exit(9); +} + +#[cold] +#[inline(never)] +fn fail_tokenize(detail: &str) -> ! { + crate::output::err_generic( + "invalid value for NODE_OPTIONS ({})", + format_args!("{}", detail), + ); + crate::Global::exit(9); +} + +/// Tokenize and validate a `NODE_OPTIONS` value, returning normalized argv +/// tokens (one `--flag` or `--flag=value` per entry) for the flags Bun +/// implements. +/// +/// - Allowed-but-unimplemented Node flags (for example +/// `--max-old-space-size`) are dropped silently, matching Bun's CLI policy +/// for unknown long flags. +/// - Flags outside Node's allowlist warn once and are dropped. Node exits +/// with status 9 here, but a hard error breaks tooling that forwards +/// `process.execArgv` (which can hold Bun-specific flags) into worker +/// NODE_OPTIONS. +/// - Positional tokens are dropped: they are either values of dropped flags +/// or entrypoint-hijack attempts. Node refuses them too. +/// - Tokenizer errors and a missing required value are fatal with status 9, +/// matching Node. +/// +/// `#[cold]`: only reached when the env var is set; the caller checks +/// `env_var::NODE_OPTIONS.get()` on the hot path so the common unset case +/// never faults this page. +#[cold] +#[inline(never)] +pub fn filter(raw: &[u8]) -> Vec> { + let tokens = match tokenize(raw) { + Ok(t) => t, + Err(TokenizeError::UnterminatedString) => fail_tokenize("unterminated string"), + Err(TokenizeError::InvalidEscape) => fail_tokenize("invalid escape"), + }; + + let mut out: Vec> = Vec::new(); + let mut warned = false; + let mut i = 0usize; + while i < tokens.len() { + let tok: &[u8] = &tokens[i]; + i += 1; + // A token with no dash is either the value of a dropped flag or a + // positional. Node stops option processing at the first positional; + // without per-flag arity for every allowed option the two cannot be + // distinguished, so skip rather than break. + if tok.is_empty() || tok[0] != b'-' || tok == b"-" { + continue; + } + let (name, inline_value) = split_name_value(tok); + let normalized = normalize(name); + match supported(&normalized) { + Some((canonical, Supported::Flag)) => out.push(canonical.to_vec()), + Some((canonical, Supported::OptionalValue)) => { + let mut arg = canonical.to_vec(); + if let Some(v) = inline_value { + arg.push(b'='); + arg.extend_from_slice(v); + } + out.push(arg); + } + Some((canonical, Supported::Value)) => { + let value: &[u8] = match inline_value { + Some(v) if !v.is_empty() => v, + Some(_) => fail_requires_argument(&normalized), + // Node's option parser takes the next token as the value + // unconditionally, even when it starts with a dash. + None => match tokens.get(i) { + Some(next) => { + i += 1; + next + } + None => fail_requires_argument(&normalized), + }, + }; + let mut arg = canonical.to_vec(); + arg.push(b'='); + arg.extend_from_slice(value); + out.push(arg); + } + None => { + if !is_allowed(&normalized) && !is_bun_flag(&normalized) && !warned { + warned = true; + warn_not_allowed(name); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toks(raw: &[u8]) -> Vec> { + filter(raw) + } + + #[test] + fn allowlist_is_sorted() { + for pair in ALLOWED.windows(2) { + assert!(pair[0] < pair[1], "{:?} >= {:?}", pair[0], pair[1]); + } + } + + #[test] + fn tokenize_basic() { + assert_eq!( + tokenize(b"--require ./a.js --import ./b.mjs").unwrap(), + vec![ + Box::<[u8]>::from(b"--require" as &[u8]), + Box::<[u8]>::from(b"./a.js" as &[u8]), + Box::<[u8]>::from(b"--import" as &[u8]), + Box::<[u8]>::from(b"./b.mjs" as &[u8]), + ], + ); + } + + #[test] + fn tokenize_quotes() { + assert_eq!( + tokenize(br#"--import "./with space.mjs""#).unwrap(), + vec![ + Box::<[u8]>::from(b"--import" as &[u8]), + Box::<[u8]>::from(b"./with space.mjs" as &[u8]), + ], + ); + assert_eq!( + tokenize(br#""a\"b""#).unwrap(), + vec![Box::<[u8]>::from(br#"a"b"# as &[u8])], + ); + assert_eq!( + tokenize(br#"--import "ab"#), + Err(TokenizeError::UnterminatedString), + ); + } + + #[test] + fn tokenize_empty() { + assert!(tokenize(b"").unwrap().is_empty()); + assert!(tokenize(b" ").unwrap().is_empty()); + } + + #[test] + fn filter_normalizes_value_flags() { + assert_eq!( + toks(b"--conditions=development"), + vec![b"--conditions=development".to_vec()], + ); + assert_eq!( + toks(b"--conditions development"), + vec![b"--conditions=development".to_vec()], + ); + assert_eq!( + toks(b"-C development -r ./a.js"), + vec![b"--conditions=development".to_vec(), b"--require=./a.js".to_vec()], + ); + assert_eq!( + toks(b"--max_http_header_size=65536"), + vec![b"--max-http-header-size=65536".to_vec()], + ); + } + + #[test] + fn filter_drops_unimplemented_and_positionals() { + assert!(toks(b"--max-old-space-size=4096").is_empty()); + assert!(toks(b"--enable-source-maps").is_empty()); + assert!(toks(b"/etc/passwd ./evil.js -").is_empty()); + assert!(toks(b"--bun --smol").is_empty()); + // The value of a dropped flag in space form falls out as a positional. + assert!(toks(b"--max-old-space-size 4096").is_empty()); + } + + #[test] + fn filter_optional_value_flags() { + assert_eq!(toks(b"--inspect"), vec![b"--inspect".to_vec()]); + assert_eq!( + toks(b"--inspect=localhost:9229"), + vec![b"--inspect=localhost:9229".to_vec()], + ); + } + + #[test] + fn allowlist_lookup() { + assert!(is_allowed(b"--import")); + assert!(is_allowed(b"--require")); + assert!(is_allowed(b"-r")); + assert!(is_allowed(b"-C")); + assert!(is_allowed(b"--max-old-space-size")); + assert!(!is_allowed(b"--definitely-not-a-real-flag")); + assert!(!is_allowed(b"--eval")); + assert!(!is_allowed(b"-e")); + } + + #[test] + fn underscore_normalization() { + assert_eq!( + &*normalize(b"--max_old_space_size"), + b"--max-old-space-size" + ); + assert_eq!(&*normalize(b"-r"), b"-r"); + } +} diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 818e0a72ad09..185453cd7362 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -3812,6 +3812,19 @@ fn argv_view_init() { append_options_env::<&'static ZStr>(opts, &mut view); set_bun_options_argc(view.len() - original_len); } + // Splice allowlisted NODE_OPTIONS tokens between argv[0] and the + // BUN_OPTIONS tokens: env options come before real CLI args (Node parity, + // so the command line wins on last-wins flags), and BUN_OPTIONS is the + // more specific env var, so it wins over NODE_OPTIONS the same way. + if !view.is_empty() { + if let Some(opts) = crate::env_var::NODE_OPTIONS.get() { + let tokens = crate::node_options::filter(opts); + set_node_options_argc(tokens.len()); + for (k, tok) in tokens.into_iter().enumerate() { + view.insert(1 + k, <&'static ZStr as OptionsEnvArg>::from_buf(tok)); + } + } + } let view: &'static [&'static ZStr] = ARGV_VIEW.get_or_init(move || view); // SAFETY: single-threaded lazy init guarded by Once. unsafe { ARGV.write(view) }; @@ -3905,6 +3918,24 @@ pub(crate) fn set_bun_options_argc(n: usize) { BUN_OPTIONS_ARGC.store(n, core::sync::atomic::Ordering::Relaxed); } +/// Number of arguments injected into `argv` by the `NODE_OPTIONS` environment +/// variable. They occupy `argv[1 .. 1 + node_options_argc]` (before the +/// BUN_OPTIONS tokens). Set once during single-threaded startup. +static NODE_OPTIONS_ARGC: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0); + +/// Read accessor for the NODE_OPTIONS-injected arg count. Forces the lazy +/// `argv_view()` init first, same as [`bun_options_argc`]. +#[inline] +pub fn node_options_argc() -> usize { + let _ = argv_view(); + NODE_OPTIONS_ARGC.load(core::sync::atomic::Ordering::Relaxed) +} +/// Write accessor (single-threaded startup). +#[inline] +pub(crate) fn set_node_options_argc(n: usize) { + NODE_OPTIONS_ARGC.store(n, core::sync::atomic::Ordering::Relaxed); +} + /// Trait for arg types accepted by [`append_options_env`]. /// Impl'd for `bun_core::String` /// and `Box` in their owning crates. diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 583c1dde12f1..604191576254 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2780,9 +2780,10 @@ __attribute__((minsize)) static JSValue constructProcessConfigObject(VM& vm, JSO putDirectNamed(vm, variables, "napi_build_version"_s, JSC::jsNumber(Napi::DEFAULT_NAPI_VERSION)); putDirectNamed(vm, variables, "node_builtin_shareable_builtins"_s, shareableBuiltins); putDirectNamed(vm, variables, "node_byteorder"_s, JSC::jsString(vm, String("little"_s))); - // Bun does not parse the NODE_OPTIONS environment variable, so report the - // same value as a Node build compiled --without-node-options; upstream - // tests gate NODE_OPTIONS-dependent cases on this key. + // Bun applies only the subset of NODE_OPTIONS flags it implements (see + // bun_core::node_options), so keep reporting the --without-node-options + // value; upstream tests gate NODE_OPTIONS-dependent cases on this key and + // assume full support when it is false. putDirectNamed(vm, variables, "node_without_node_options"_s, JSC::jsBoolean(true)); putDirectNamed(vm, variables, "clang"_s, JSC::jsNumber(0)); diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index b10463298572..c91d67a4c246 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1349,7 +1349,9 @@ pub mod command { // standalone executable silently drops `BUN_OPTIONS` flags. let original_argv_len = bun::argv().len(); let bun_options_argc = bun::bun_options_argc(); - if !graph.compile_exec_argv.is_empty() || bun_options_argc > 0 { + let node_options_argc = bun::node_options_argc(); + if !graph.compile_exec_argv.is_empty() || bun_options_argc > 0 || node_options_argc > 0 + { let mut argv_list: Vec<&'static bun_core::ZStr> = bun::argv().to_vec(); if !graph.compile_exec_argv.is_empty() { bun::append_options_env(graph.compile_exec_argv, &mut argv_list); @@ -1359,8 +1361,9 @@ pub mod command { let full_argv: &'static [&'static bun_core::ZStr] = bun::intern_argv(argv_list); let num_exec_argv_options = full_argv.len().saturating_sub(original_argv_len); - // Calculate offset: skip executable name + all exec argv options + BUN_OPTIONS args - let num_parsed_options = num_exec_argv_options + bun_options_argc; + // Calculate offset: skip executable name + all exec argv + // options + BUN_OPTIONS / NODE_OPTIONS args + let num_parsed_options = num_exec_argv_options + bun_options_argc + node_options_argc; offset_for_passthrough = if full_argv.len() > 1 { 1 + num_parsed_options } else { diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 0f99389d1334..ac1374a39e7c 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -276,6 +276,11 @@ mod _impl { // it isn't worth doing this as a part of the CLI let mut iter = argv.iter(); let _ = iter.next(); // skip argv[0] + // NODE_OPTIONS-injected tokens occupy argv[1 .. 1 + node_options_argc]. + // Node does not report env-derived options in process.execArgv. + for _ in 0..bun_core::node_options_argc() { + let _ = iter.next(); + } for arg in iter { // emulate `defer prev = arg` by setting at end of each iteration body let arg: &[u8] = arg; diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts new file mode 100644 index 000000000000..463a3cc1c987 --- /dev/null +++ b/test/cli/env/node-options.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +async function run( + dir: string, + cmd: string[], + NODE_OPTIONS: string | undefined, +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + const env: Record = { ...bunEnv, NODE_OPTIONS }; + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("NODE_OPTIONS environment variable", () => { + // The repro from issue #40316: a package.json `imports` map with a + // `development` condition. react-router dev 8.3+ relaunches itself with + // NODE_OPTIONS=--conditions=development and breaks when it is ignored. + const conditionsFixtures = { + "package.json": `{"name":"cond","type":"module","imports":{"#c":{"development":"./true.mjs","default":"./false.mjs"}}}`, + "true.mjs": `export default true;\n`, + "false.mjs": `export default false;\n`, + "probe.mjs": `import v from "#c"; console.log(v);\n`, + }; + + describe.each([ + ["--conditions=development"], + ["--conditions development"], + ["-C development"], + [`--conditions "development"`], + ])("applies --conditions via %s", opts => { + test.concurrent("bun ", async () => { + using dir = tempDir("node-options-cond", conditionsFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["probe.mjs"], opts); + expect(stderr).not.toContain("not allowed"); + expect({ stdout, exitCode }).toEqual({ stdout: "true\n", exitCode: 0 }); + }); + + test.concurrent("bun run ", async () => { + using dir = tempDir("node-options-cond-run", conditionsFixtures); + const { stdout, exitCode } = await run(String(dir), ["run", "probe.mjs"], opts); + expect({ stdout, exitCode }).toEqual({ stdout: "true\n", exitCode: 0 }); + }); + }); + + const preloadFixtures = { + "pre.mjs": `globalThis.__PRELOADED = true;\n`, + "app.mjs": `console.log(globalThis.__PRELOADED === true ? "PRELOADED" : "NOT_PRELOADED");\n`, + }; + + describe.each([ + ["--import", "--import ./pre.mjs"], + ["--import=", "--import=./pre.mjs"], + ["--require", "--require ./pre.mjs"], + ["--require=", "--require=./pre.mjs"], + ["-r", "-r ./pre.mjs"], + ])("preloads via %s", (_name, opts) => { + test.concurrent("bun ", async () => { + using dir = tempDir("node-options-preload", preloadFixtures); + const { stdout, exitCode } = await run(String(dir), ["app.mjs"], opts); + expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); + }); + }); + + test.concurrent("applies --title", async () => { + using dir = tempDir("node-options-title", {}); + const { stdout, exitCode } = await run(String(dir), ["-e", "console.log(process.title)"], "--title=from-env"); + expect({ stdout, exitCode }).toEqual({ stdout: "from-env\n", exitCode: 0 }); + }); + + test.concurrent("applies --dns-result-order (issue #28817)", async () => { + using dir = tempDir("node-options-dns", {}); + const { stdout, exitCode } = await run( + String(dir), + ["-e", `import dns from "node:dns"; console.log(dns.getDefaultResultOrder())`], + "--dns-result-order=ipv4first", + ); + expect({ stdout, exitCode }).toEqual({ stdout: "ipv4first\n", exitCode: 0 }); + }); + + test.concurrent("command-line flags win over NODE_OPTIONS", async () => { + using dir = tempDir("node-options-precedence", {}); + const { stdout, exitCode } = await run( + String(dir), + ["--title=from-cli", "-e", "console.log(process.title)"], + "--title=from-env", + ); + expect({ stdout, exitCode }).toEqual({ stdout: "from-cli\n", exitCode: 0 }); + }); + + test.concurrent("NODE_OPTIONS flags are not reported in process.execArgv", async () => { + using dir = tempDir("node-options-execargv", { + "argv.mjs": `console.log(JSON.stringify(process.execArgv));\n`, + }); + const { stdout, exitCode } = await run(String(dir), ["--conditions=cli", "argv.mjs"], "--conditions=development"); + expect({ stdout, exitCode }).toEqual({ stdout: '["--conditions=cli"]\n', exitCode: 0 }); + }); + + test.concurrent("double-quoted values may contain spaces", async () => { + using dir = tempDir("node-options-quotes", { + "with space": { "sp.mjs": `console.log("SP");\n` }, + }); + const { stdout, exitCode } = await run( + String(dir), + ["-e", "console.log('main')"], + `--import "./with space/sp.mjs"`, + ); + expect({ stdout, exitCode }).toEqual({ stdout: "SP\nmain\n", exitCode: 0 }); + }); + + test.concurrent("unknown flag warns but does not exit", async () => { + using dir = tempDir("node-options-unknown", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], "--definitely-not-a-real-flag"); + expect(stderr).toContain("--definitely-not-a-real-flag is not allowed in NODE_OPTIONS"); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }); + + test.concurrent("disallowed flag (--eval) warns and does not evaluate", async () => { + using dir = tempDir("node-options-eval", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], `--eval "console.log('HIJACK')"`); + expect(stderr).toContain("--eval is not allowed in NODE_OPTIONS"); + expect(stdout).not.toContain("HIJACK"); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }); + + test.concurrent("positional tokens cannot change the entrypoint", async () => { + using dir = tempDir("node-options-positional", { + ...preloadFixtures, + "evil.mjs": `console.log("EVIL");\n`, + }); + const { stdout, exitCode } = await run(String(dir), ["app.mjs"], "./evil.mjs"); + expect(stdout).not.toContain("EVIL"); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }); + + test.concurrent("unknown flag after a preload still preloads and warns once", async () => { + using dir = tempDir("node-options-mixed", preloadFixtures); + const { stdout, stderr, exitCode } = await run( + String(dir), + ["app.mjs"], + "--import ./pre.mjs --definitely-not-a-real-flag --also-junk", + ); + expect(stderr).toContain("--definitely-not-a-real-flag is not allowed in NODE_OPTIONS"); + expect(stderr).not.toContain("--also-junk"); + expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); + }); + + test.concurrent("Bun-specific flags forwarded via execArgv are accepted silently", async () => { + using dir = tempDir("node-options-bunflag", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], "--bun --import ./pre.mjs"); + expect(stderr).not.toContain("is not allowed in NODE_OPTIONS"); + expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); + }); + + test.each([ + ["--import", "--import"], + ["--import=", "--import="], + ["--require=", "--require="], + ["-r", "-r"], + ["--conditions", "--conditions"], + ])("required value missing for %s is rejected with exit code 9", async (_name, opts) => { + using dir = tempDir("node-options-noval", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], opts); + expect(stderr).toContain("requires an argument"); + expect(stdout).toBe(""); + expect(exitCode).toBe(9); + }); + + test.concurrent("unterminated double quote is rejected with exit code 9", async () => { + using dir = tempDir("node-options-badquote", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], `--import "foo`); + expect(stderr).toContain("invalid value for NODE_OPTIONS (unterminated string)"); + expect(stdout).toBe(""); + expect(exitCode).toBe(9); + }); + + describe.each([ + ["V8 flag (= form)", "--max-old-space-size=4096"], + ["V8 flag (underscore form)", "--max_old_space_size=4096"], + ["V8 flag (space form)", "--max-old-space-size 4096"], + ["experimental flag", "--experimental-vm-modules"], + ["--enable-source-maps", "--enable-source-maps"], + ["bare -", "-"], + ])("ignores allowed Node flag silently: %s", (_name, opts) => { + test.concurrent("does not warn", async () => { + using dir = tempDir("node-options-allowed", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], opts); + expect(stderr).not.toContain("is not allowed in NODE_OPTIONS"); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }); + }); + + describe.each([ + ["unset", undefined], + ["empty string", ""], + ["whitespace only", " "], + ])("no-op when NODE_OPTIONS is %s", (_name, opts) => { + test.concurrent("runs normally", async () => { + using dir = tempDir("node-options-empty", preloadFixtures); + const { stdout, exitCode } = await run(String(dir), ["app.mjs"], opts); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }); + }); + + test.concurrent("does not break bun install", async () => { + using dir = tempDir("node-options-install", { + "package.json": `{"name":"x","version":"1.0.0"}`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + env: { ...bunEnv, NODE_OPTIONS: "--conditions=development --max-old-space-size=4096" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("not allowed"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/harness.ts b/test/harness.ts index 016b40e96dad..52afca4fd828 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -68,6 +68,9 @@ export const bunEnv: NodeJS.Dict = { // Strip ad-hoc JSC debug options that may be set on CI agents — they leak // a "WARNING: failed to parse" line to stderr that breaks snapshot tests. JSC_useJIT: undefined, + // Bun reads NODE_OPTIONS; an ambient value on the host would leak flags + // (or warnings about them) into every spawned test process. + NODE_OPTIONS: undefined, GITHUB_ACTIONS: "false", BUN_DEBUG_QUIET_LOGS: "1", NO_COLOR: "1", From 8bf28b19c07e560906e4c53dab41b5540bc25094 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:02:56 +0000 Subject: [PATCH 02/15] [autofix.ci] apply automated fixes --- src/bun_core/node_options.rs | 5 ++++- src/runtime/cli/mod.rs | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index 3af3b49e237b..6b2bc6e45f7d 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -630,7 +630,10 @@ mod tests { ); assert_eq!( toks(b"-C development -r ./a.js"), - vec![b"--conditions=development".to_vec(), b"--require=./a.js".to_vec()], + vec![ + b"--conditions=development".to_vec(), + b"--require=./a.js".to_vec() + ], ); assert_eq!( toks(b"--max_http_header_size=65536"), diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index c91d67a4c246..4572d5fa5110 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1363,7 +1363,8 @@ pub mod command { // Calculate offset: skip executable name + all exec argv // options + BUN_OPTIONS / NODE_OPTIONS args - let num_parsed_options = num_exec_argv_options + bun_options_argc + node_options_argc; + let num_parsed_options = + num_exec_argv_options + bun_options_argc + node_options_argc; offset_for_passthrough = if full_argv.len() > 1 { 1 + num_parsed_options } else { From 4b0fd530512529b777ca0423a6f4fc0b45582d8e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:24:11 +0000 Subject: [PATCH 03/15] Skip the NODE_OPTIONS argv window in subcommands that index raw argv bun init, bun info, and bun whoami read the subcommand keyword or its arguments at fixed argv offsets. The injected NODE_OPTIONS tokens shifted those offsets, so bun init created a directory named init and bun info queried the package named info. Skip node_options_argc() at those sites. Also drain stdout in the bun install test. --- src/runtime/cli/mod.rs | 11 +++-- src/runtime/cli/package_manager_command.rs | 6 ++- test/cli/env/node-options.test.ts | 51 +++++++++++++++++++++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 4572d5fa5110..a32d7efa61aa 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1500,9 +1500,11 @@ pub mod command { #[cold] #[inline(never)] fn exec_init() -> CmdResult { - // InitCommand parses its own argv (no Context). + // InitCommand parses its own argv (no Context). Skip past argv[0], + // the NODE_OPTIONS-injected window, and the "init" keyword. let argv = argv_zslice(); - super::init_command::InitCommand::exec(&argv[2.min(argv.len())..]) + let start = 2 + bun::node_options_argc(); + super::init_command::InitCommand::exec(&argv[start.min(argv.len())..]) } #[cold] @@ -1941,10 +1943,11 @@ To create a project with the official Next.js scaffolding tool, run\n\ let mut package_name: &[u8] = b""; let mut property_path: Option<&[u8]> = None; - // Find non-flag arguments starting from argv[2] (after "bun info"). + // Find non-flag arguments starting after "bun info" plus the + // NODE_OPTIONS-injected window. let mut found_package = false; let argv = bun::argv(); - for arg in argv.iter().skip(2) { + for arg in argv.iter().skip(2 + bun::node_options_argc()) { // Skip flags if !arg.is_empty() && arg[0] == b'-' { continue; diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 7c66aafb4657..26cdcf89296e 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -240,9 +240,11 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; let args_vec: Vec<&'static [u8]> = bun_core::argv().into_iter().skip(1).collect(); let args: &[&[u8]] = &args_vec; - // Check if we're being invoked directly as "bun whoami" instead of "bun pm whoami" + // Check if we're being invoked directly as "bun whoami" instead of + // "bun pm whoami". The keyword sits after the NODE_OPTIONS-injected + // window. let is_direct_whoami = bun_core::argv() - .get(1) + .get(1 + bun_core::node_options_argc()) .is_some_and(|arg| strings::eql_comptime(arg.as_bytes(), b"whoami")); let cli = CommandLineArguments::parse(Subcommand::Pm)?; diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 463a3cc1c987..e094fbb5bdc5 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -219,8 +219,57 @@ describe("NODE_OPTIONS environment variable", () => { stdout: "pipe", stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).not.toContain("not allowed"); expect(exitCode).toBe(0); }); + + // The injected tokens shift argv. Subcommands that index raw argv must + // skip the injected window, or they read their own keyword as an argument. + test.concurrent("bun init initializes the cwd, not a directory named 'init'", async () => { + using dir = tempDir("node-options-init", {}); + const { exitCode } = await run(String(dir), ["init", "-y"], "--title=from-env"); + expect(await Bun.file(`${String(dir)}/package.json`).exists()).toBe(true); + expect(await Bun.file(`${String(dir)}/init/package.json`).exists()).toBe(false); + expect(exitCode).toBe(0); + }); + + test.concurrent("bun info queries the requested package, not 'info'", async () => { + const requested: string[] = []; + await using server = Bun.serve({ + port: 0, + fetch(req) { + requested.push(new URL(req.url).pathname); + return new Response("{}", { status: 404 }); + }, + }); + using dir = tempDir("node-options-info", { + "package.json": `{"name":"x","version":"1.0.0"}`, + }); + await Bun.write( + `${String(dir)}/bunfig.toml`, + `[install]\nregistry = "http://localhost:${server.port}/"\n`, + ); + await run(String(dir), ["info", "react"], "--title=from-env"); + expect(requested[0]).toBe("/react"); + }); + + test.concurrent("bun whoami still resolves to whoami", async () => { + await using server = Bun.serve({ + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/-/whoami") return Response.json({ username: "testuser" }); + return new Response("{}", { status: 404 }); + }, + }); + using dir = tempDir("node-options-whoami", { + "package.json": `{"name":"x","version":"1.0.0"}`, + }); + await Bun.write( + `${String(dir)}/.npmrc`, + `//localhost:${server.port}/:_authToken=dummy\nregistry=http://localhost:${server.port}/\n`, + ); + const { stdout, exitCode } = await run(String(dir), ["whoami"], "--title=from-env"); + expect({ stdout, exitCode }).toEqual({ stdout: "testuser\n", exitCode: 0 }); + }); }); From cf323835f8d33aa9e612a76b4a07b7f1adb0354d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:27:10 +0000 Subject: [PATCH 04/15] [autofix.ci] apply automated fixes --- test/cli/env/node-options.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index e094fbb5bdc5..e0789b47522a 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -246,10 +246,7 @@ describe("NODE_OPTIONS environment variable", () => { using dir = tempDir("node-options-info", { "package.json": `{"name":"x","version":"1.0.0"}`, }); - await Bun.write( - `${String(dir)}/bunfig.toml`, - `[install]\nregistry = "http://localhost:${server.port}/"\n`, - ); + await Bun.write(`${String(dir)}/bunfig.toml`, `[install]\nregistry = "http://localhost:${server.port}/"\n`); await run(String(dir), ["info", "react"], "--title=from-env"); expect(requested[0]).toBe("/react"); }); From 9fd83487b4a66c642e7a049ecf191316a2e8de31 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:58:35 +0000 Subject: [PATCH 05/15] Skip the NODE_OPTIONS argv window in bun upgrade's argument check Same offset class as bun init and bun info: with an injected token, skip(2) landed on the upgrade keyword itself and the command exited 1 with 'does not take package names'. --- src/runtime/cli/upgrade_command.rs | 8 +++++--- test/cli/env/node-options.test.ts | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index 616cc19ff291..d226dce855ba 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -508,13 +508,15 @@ impl UpgradeCommand { #[cold] pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> { let args = bun_core::argv(); - if args.len() > 2 { - for arg in args.iter().skip(2) { + // "bun upgrade" plus the NODE_OPTIONS-injected window. + let skip_n = 2 + bun_core::node_options_argc(); + if args.len() > skip_n { + for arg in args.iter().skip(skip_n) { if !strings::contains(arg, b"--") { bun_core::pretty_error!( "error: This command updates Bun itself, and does not take package names.\nnote: Use `bun update" ); - for arg_err in args.iter().skip(2) { + for arg_err in args.iter().skip(skip_n) { bun_core::pretty_error!(" {}", bstr::BStr::new(arg_err)); } bun_core::pretty_errorln!("` instead."); diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index e0789b47522a..040568728438 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -251,6 +251,16 @@ describe("NODE_OPTIONS environment variable", () => { expect(requested[0]).toBe("/react"); }); + test.concurrent("bun upgrade does not read its own keyword as a package name", async () => { + using dir = tempDir("node-options-upgrade", {}); + // Exits before any network access. Without the offset fix the message + // lists the shifted window: "bun update upgrade somepkg". + const { stderr, exitCode } = await run(String(dir), ["upgrade", "somepkg"], "--title=from-env"); + expect(stderr).toContain("does not take package names"); + expect(stderr).toContain("`bun update somepkg`"); + expect(exitCode).toBe(1); + }); + test.concurrent("bun whoami still resolves to whoami", async () => { await using server = Bun.serve({ port: 0, From 961d05021aa84bc182b472c8c22681454f05b27b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:18:35 +0000 Subject: [PATCH 06/15] Add injected_argv_argc, allowedNodeEnvironmentFlags entries, and a drift check - injected_argv_argc() sums the NODE_OPTIONS and BUN_OPTIONS windows so positional consumers offset by one accessor. boot_standalone uses it. - Add -C, --expose-gc, --inspect-wait, and --unhandled-rejections to process.allowedNodeEnvironmentFlags, and test that every flag applied from NODE_OPTIONS is in the set. - Debug builds assert each spliced flag is declared in AUTO_PARAMS, so a flag that leaves the param tables cannot silently become a no-op. - Test NODE_OPTIONS and BUN_OPTIONS together: BUN_OPTIONS wins, and execArgv hides only the NODE_OPTIONS tokens. --- src/bun_core/node_options.rs | 77 ++++++++++++++--------- src/bun_core/util.rs | 9 +++ src/js/builtins/ProcessObjectInternals.ts | 4 ++ src/runtime/cli/Arguments.rs | 18 ++++++ src/runtime/cli/mod.rs | 11 ++-- src/runtime/node/node_process.rs | 4 +- test/cli/env/node-options.test.ts | 50 +++++++++++++++ 7 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index 6b2bc6e45f7d..bd710c735d10 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -421,36 +421,53 @@ enum Supported { OptionalValue, } -/// The subset of Node's allowlist that Bun's CLI implements, plus Node's -/// short forms (`-r`, `-C`) mapped to the long spelling Bun declares. -fn supported(flag: &[u8]) -> Option<(&'static [u8], Supported)> { - Some(match flag { - b"--conditions" | b"-C" => (b"--conditions", Supported::Value), - b"--require" | b"-r" => (b"--require", Supported::Value), - b"--import" => (b"--import", Supported::Value), - b"--disable-warning" => (b"--disable-warning", Supported::Value), - b"--dns-result-order" => (b"--dns-result-order", Supported::Value), - b"--max-http-header-size" => (b"--max-http-header-size", Supported::Value), - b"--redirect-warnings" => (b"--redirect-warnings", Supported::Value), - b"--title" => (b"--title", Supported::Value), - b"--unhandled-rejections" => (b"--unhandled-rejections", Supported::Value), - b"--expose-gc" => (b"--expose-gc", Supported::Flag), - b"--no-addons" => (b"--no-addons", Supported::Flag), - b"--no-deprecation" => (b"--no-deprecation", Supported::Flag), - b"--no-warnings" => (b"--no-warnings", Supported::Flag), - b"--pending-deprecation" => (b"--pending-deprecation", Supported::Flag), - b"--preserve-symlinks" => (b"--preserve-symlinks", Supported::Flag), - b"--preserve-symlinks-main" => (b"--preserve-symlinks-main", Supported::Flag), - b"--throw-deprecation" => (b"--throw-deprecation", Supported::Flag), - b"--trace-deprecation" => (b"--trace-deprecation", Supported::Flag), - b"--trace-warnings" => (b"--trace-warnings", Supported::Flag), - b"--use-system-ca" => (b"--use-system-ca", Supported::Flag), - b"--zero-fill-buffers" => (b"--zero-fill-buffers", Supported::Flag), - b"--inspect" => (b"--inspect", Supported::OptionalValue), - b"--inspect-brk" => (b"--inspect-brk", Supported::OptionalValue), - b"--inspect-wait" => (b"--inspect-wait", Supported::OptionalValue), - _ => return None, - }) +/// The subset of Node's allowlist that Bun's CLI implements. Each entry maps +/// the accepted spellings (Node's short forms `-r` / `-C` included) to the +/// long spelling Bun declares in its param tables. +struct SplicedFlag { + names: &'static [&'static [u8]], + canonical: &'static [u8], + kind: Supported, +} + +#[rustfmt::skip] +static SPLICED: &[SplicedFlag] = &[ + SplicedFlag { names: &[b"--conditions", b"-C"], canonical: b"--conditions", kind: Supported::Value }, + SplicedFlag { names: &[b"--require", b"-r"], canonical: b"--require", kind: Supported::Value }, + SplicedFlag { names: &[b"--import"], canonical: b"--import", kind: Supported::Value }, + SplicedFlag { names: &[b"--disable-warning"], canonical: b"--disable-warning", kind: Supported::Value }, + SplicedFlag { names: &[b"--dns-result-order"], canonical: b"--dns-result-order", kind: Supported::Value }, + SplicedFlag { names: &[b"--max-http-header-size"], canonical: b"--max-http-header-size", kind: Supported::Value }, + SplicedFlag { names: &[b"--redirect-warnings"], canonical: b"--redirect-warnings", kind: Supported::Value }, + SplicedFlag { names: &[b"--title"], canonical: b"--title", kind: Supported::Value }, + SplicedFlag { names: &[b"--unhandled-rejections"], canonical: b"--unhandled-rejections", kind: Supported::Value }, + SplicedFlag { names: &[b"--expose-gc"], canonical: b"--expose-gc", kind: Supported::Flag }, + SplicedFlag { names: &[b"--no-addons"], canonical: b"--no-addons", kind: Supported::Flag }, + SplicedFlag { names: &[b"--no-deprecation"], canonical: b"--no-deprecation", kind: Supported::Flag }, + SplicedFlag { names: &[b"--no-warnings"], canonical: b"--no-warnings", kind: Supported::Flag }, + SplicedFlag { names: &[b"--pending-deprecation"], canonical: b"--pending-deprecation", kind: Supported::Flag }, + SplicedFlag { names: &[b"--preserve-symlinks"], canonical: b"--preserve-symlinks", kind: Supported::Flag }, + SplicedFlag { names: &[b"--preserve-symlinks-main"], canonical: b"--preserve-symlinks-main", kind: Supported::Flag }, + SplicedFlag { names: &[b"--throw-deprecation"], canonical: b"--throw-deprecation", kind: Supported::Flag }, + SplicedFlag { names: &[b"--trace-deprecation"], canonical: b"--trace-deprecation", kind: Supported::Flag }, + SplicedFlag { names: &[b"--trace-warnings"], canonical: b"--trace-warnings", kind: Supported::Flag }, + SplicedFlag { names: &[b"--use-system-ca"], canonical: b"--use-system-ca", kind: Supported::Flag }, + SplicedFlag { names: &[b"--zero-fill-buffers"], canonical: b"--zero-fill-buffers", kind: Supported::Flag }, + SplicedFlag { names: &[b"--inspect"], canonical: b"--inspect", kind: Supported::OptionalValue }, + SplicedFlag { names: &[b"--inspect-brk"], canonical: b"--inspect-brk", kind: Supported::OptionalValue }, + SplicedFlag { names: &[b"--inspect-wait"], canonical: b"--inspect-wait", kind: Supported::OptionalValue }, +]; + +/// Canonical long spellings `filter` can splice into argv. The CLI crate +/// cross-checks these against its param tables in debug builds, so a flag +/// that leaves the tables cannot silently become a no-op here. +pub fn spliced_long_flags() -> impl Iterator { + SPLICED.iter().map(|f| f.canonical) +} + +fn supported(flag: &[u8]) -> Option<(&'static [u8], &'static Supported)> { + let entry = SPLICED.iter().find(|f| f.names.iter().any(|n| *n == flag))?; + Some((entry.canonical, &entry.kind)) } #[cold] diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 185453cd7362..b0e532617162 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -3936,6 +3936,15 @@ pub(crate) fn set_node_options_argc(n: usize) { NODE_OPTIONS_ARGC.store(n, core::sync::atomic::Ordering::Relaxed); } +/// Total env-injected argv tokens. They occupy the contiguous window +/// `argv[1 .. 1 + injected_argv_argc()]` (NODE_OPTIONS first, then +/// BUN_OPTIONS). A consumer that locates real CLI arguments by position must +/// offset by this sum, not by one of the two counters alone. +#[inline] +pub fn injected_argv_argc() -> usize { + node_options_argc() + bun_options_argc() +} + /// Trait for arg types accepted by [`append_options_env`]. /// Impl'd for `bun_core::String` /// and `Box` in their owning crates. diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37501f664d0b..a8d54827d5a8 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -800,14 +800,17 @@ export function buildAllowedNodeEnvironmentFlags() { // a frozen Set whose has() normalizes _→-, missing dashes, and =value suffixes. const canonical = [ "--conditions", + "-C", "--diagnostic-dir", "--disable-warning", "--dns-result-order", "--enable-source-maps", + "--expose-gc", "--import", "--inspect", "--inspect-brk", "--inspect-port", + "--inspect-wait", "--max-http-header-size", "--no-addons", "--no-deprecation", @@ -827,6 +830,7 @@ export function buildAllowedNodeEnvironmentFlags() { "--title", "--trace-deprecation", "--trace-warnings", + "--unhandled-rejections", "--use-bundled-ca", "--use-openssl-ca", "--use-system-ca", diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 32efb21ae4f1..e66d28bfd308 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -760,6 +760,24 @@ pub const NODE_SHORT_ALIASES: &[(&[u8], &[u8])] = &[(b"-pe", b"-p")]; /// param table, and the per-`cmd` blocks below are guarded by /// `if matches!(cmd, …)`. pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { + #[cfg(debug_assertions)] + { + // Every flag node_options::filter can splice into argv must stay + // declared in AUTO_PARAMS, or that NODE_OPTIONS flag silently becomes + // a no-op for runtime commands. + static CHECKED: std::sync::Once = std::sync::Once::new(); + CHECKED.call_once(|| { + for flag in bun_core::node_options::spliced_long_flags() { + let name = &flag[2..]; + assert!( + AUTO_PARAMS.iter().any(|p| p.names.long == Some(name)), + "NODE_OPTIONS splices {} but AUTO_PARAMS does not declare it", + BStr::new(flag), + ); + } + }); + } + let mut diag = clap::Diagnostic::default(); let table = tag_table(cmd); diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index a32d7efa61aa..8ab1d0061bcb 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1348,10 +1348,8 @@ pub mod command { // now — otherwise `bun_options_argc()` reads 0 here and the // standalone executable silently drops `BUN_OPTIONS` flags. let original_argv_len = bun::argv().len(); - let bun_options_argc = bun::bun_options_argc(); - let node_options_argc = bun::node_options_argc(); - if !graph.compile_exec_argv.is_empty() || bun_options_argc > 0 || node_options_argc > 0 - { + let injected_argc = bun::injected_argv_argc(); + if !graph.compile_exec_argv.is_empty() || injected_argc > 0 { let mut argv_list: Vec<&'static bun_core::ZStr> = bun::argv().to_vec(); if !graph.compile_exec_argv.is_empty() { bun::append_options_env(graph.compile_exec_argv, &mut argv_list); @@ -1362,9 +1360,8 @@ pub mod command { let num_exec_argv_options = full_argv.len().saturating_sub(original_argv_len); // Calculate offset: skip executable name + all exec argv - // options + BUN_OPTIONS / NODE_OPTIONS args - let num_parsed_options = - num_exec_argv_options + bun_options_argc + node_options_argc; + // options + env-injected args + let num_parsed_options = num_exec_argv_options + injected_argc; offset_for_passthrough = if full_argv.len() > 1 { 1 + num_parsed_options } else { diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index ac1374a39e7c..e0c8beaa11f7 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -277,7 +277,9 @@ mod _impl { let mut iter = argv.iter(); let _ = iter.next(); // skip argv[0] // NODE_OPTIONS-injected tokens occupy argv[1 .. 1 + node_options_argc]. - // Node does not report env-derived options in process.execArgv. + // Node does not report env-derived options in process.execArgv. The + // BUN_OPTIONS tokens after them stay visible on purpose: the + // standalone path above rebuilds execArgv from BUN_OPTIONS too. for _ in 0..bun_core::node_options_argc() { let _ = iter.next(); } diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 040568728438..ef63105a7a65 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -208,6 +208,56 @@ describe("NODE_OPTIONS environment variable", () => { }); }); + test.concurrent("BUN_OPTIONS wins over NODE_OPTIONS, execArgv hides only NODE_OPTIONS", async () => { + using dir = tempDir("node-options-both", { + "argv.mjs": `console.log(process.title, JSON.stringify(process.execArgv));\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "argv.mjs"], + env: { ...bunEnv, NODE_OPTIONS: "--title=from-node", BUN_OPTIONS: "--title=from-bun" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // BUN_OPTIONS tokens stay visible in execArgv (pre-existing Bun + // behavior); NODE_OPTIONS tokens are hidden (Node parity). + expect({ stdout, exitCode }).toEqual({ stdout: 'from-bun ["--title=from-bun"]\n', exitCode: 0 }); + }); + + test.concurrent("every applied flag is in process.allowedNodeEnvironmentFlags", () => { + const applied = [ + "--conditions", + "-C", + "--require", + "-r", + "--import", + "--disable-warning", + "--dns-result-order", + "--max-http-header-size", + "--redirect-warnings", + "--title", + "--unhandled-rejections", + "--expose-gc", + "--no-addons", + "--no-deprecation", + "--no-warnings", + "--pending-deprecation", + "--preserve-symlinks", + "--preserve-symlinks-main", + "--throw-deprecation", + "--trace-deprecation", + "--trace-warnings", + "--use-system-ca", + "--zero-fill-buffers", + "--inspect", + "--inspect-brk", + "--inspect-wait", + ]; + expect(applied.filter(flag => !process.allowedNodeEnvironmentFlags.has(flag))).toEqual([]); + }); + test.concurrent("does not break bun install", async () => { using dir = tempDir("node-options-install", { "package.json": `{"name":"x","version":"1.0.0"}`, From 24d0dd47040c5f9f299f717698b1d2dbbb59f7ee Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:21:34 +0000 Subject: [PATCH 07/15] [autofix.ci] apply automated fixes --- src/bun_core/node_options.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index bd710c735d10..17d5d9b73b16 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -466,7 +466,9 @@ pub fn spliced_long_flags() -> impl Iterator { } fn supported(flag: &[u8]) -> Option<(&'static [u8], &'static Supported)> { - let entry = SPLICED.iter().find(|f| f.names.iter().any(|n| *n == flag))?; + let entry = SPLICED + .iter() + .find(|f| f.names.iter().any(|n| *n == flag))?; Some((entry.canonical, &entry.kind)) } From 9ded52dcf48e3646c085345aebaa2e1e2f6735a8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:38:18 +0000 Subject: [PATCH 08/15] ci: retrigger From bc363aa7b5f0a7f9deee213b675e5a9199a5f046 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:45:39 +0000 Subject: [PATCH 09/15] Skip the NODE_OPTIONS argv window in the pm args slice TrustCommand indexes args[2..] assuming the slice starts at the pm keyword. With an injected token the empty-args guard was bypassed and the trust keyword itself was read as a package name. --- src/runtime/cli/package_manager_command.rs | 13 +++++++++---- test/cli/env/node-options.test.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 26cdcf89296e..4fbf49c168d3 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -234,10 +234,15 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; } pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> { - // `bun_core::argv()` includes argv[0]; skip it and collect into a - // borrowed-slice Vec so `&[&[u8]]` callers (TrustCommand/UntrustedCommand, - // `left_has_any_in_right`) keep their shape. - let args_vec: Vec<&'static [u8]> = bun_core::argv().into_iter().skip(1).collect(); + // `bun_core::argv()` includes argv[0]; skip it plus the + // NODE_OPTIONS-injected window and collect into a borrowed-slice Vec + // so `&[&[u8]]` callers (TrustCommand/UntrustedCommand, + // `left_has_any_in_right`) keep their shape: TrustCommand indexes + // `args[2..]` assuming `args` starts at the `pm` keyword. + let args_vec: Vec<&'static [u8]> = bun_core::argv() + .into_iter() + .skip(1 + bun_core::node_options_argc()) + .collect(); let args: &[&[u8]] = &args_vec; // Check if we're being invoked directly as "bun whoami" instead of diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index ef63105a7a65..506709c0aed0 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -311,6 +311,17 @@ describe("NODE_OPTIONS environment variable", () => { expect(exitCode).toBe(1); }); + test.concurrent("bun pm trust still requires package names", async () => { + using dir = tempDir("node-options-trust", { + "package.json": `{"name":"x","version":"1.0.0"}`, + }); + // Without the offset fix the empty-args guard is bypassed and the + // command proceeds to load the lockfile. + const { stderr, exitCode } = await run(String(dir), ["pm", "trust"], "--title=from-env"); + expect(stderr).toContain("expected package names(s) or --all"); + expect(exitCode).toBe(1); + }); + test.concurrent("bun whoami still resolves to whoami", async () => { await using server = Bun.serve({ port: 0, From bf2ebbc36dd25375f1416480ad38baab7b4c459b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:37:37 +0000 Subject: [PATCH 10/15] Skip the NODE_OPTIONS argv window in the bunx internal dispatch check which() reads argv[1] to route bunx's internal install child to AddCommand or ExecCommand. With an injected token it read the token instead of the keyword and fell through to BunxCommand. --- src/runtime/cli/mod.rs | 4 +++- test/cli/env/node-options.test.ts | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 8ab1d0061bcb..7bdb88b83cf2 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -907,7 +907,9 @@ pub mod command { }; if is_bun_x(argv0) { - if let Some(next) = argv.get(1) { + // The internal "add"/"exec" keyword sits after the + // NODE_OPTIONS-injected window. + if let Some(next) = argv.get(1 + bun::node_options_argc()) { let next_bytes = next.as_bytes(); if next_bytes == b"add" && bun_core::env_var::feature_flag::BUN_INTERNAL_BUNX_INSTALL.get() diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 506709c0aed0..72e2601fd784 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { symlinkSync } from "node:fs"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; async function run( dir: string, @@ -322,6 +323,25 @@ describe("NODE_OPTIONS environment variable", () => { expect(exitCode).toBe(1); }); + // bunx's internal install child re-enters which() with argv0 = bunx and + // BUN_INTERNAL_BUNX_INSTALL=true; the "add" keyword must still be found + // after the injected window or the child loops as bunx (#39377 class). + test.skipIf(isWindows)("bunx internal add dispatch still finds the keyword", async () => { + using dir = tempDir("node-options-bunx", {}); + symlinkSync(bunExe(), `${String(dir)}/bunx`); + await using proc = Bun.spawn({ + cmd: [`${String(dir)}/bunx`, "add", "--help"], + env: { ...bunEnv, NODE_OPTIONS: "--title=from-env", BUN_INTERNAL_BUNX_INSTALL: "true" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("bun add"); + expect(stderr).not.toContain("error"); + expect(exitCode).toBe(0); + }); + test.concurrent("bun whoami still resolves to whoami", async () => { await using server = Bun.serve({ port: 0, From 1d7ceb816d2ac3439a4c199db0744dff44931e25 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:40:13 +0000 Subject: [PATCH 11/15] [autofix.ci] apply automated fixes --- test/cli/env/node-options.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 72e2601fd784..5e533d5c7f25 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { symlinkSync } from "node:fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { symlinkSync } from "node:fs"; async function run( dir: string, From 2aeb4fe31cb9e9a602eb6907f430448f2ae7b535 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:34:42 +0000 Subject: [PATCH 12/15] Use slice contains for the spliced flag lookup (clippy) --- src/bun_core/node_options.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index 17d5d9b73b16..b11bd37b3aca 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -466,9 +466,7 @@ pub fn spliced_long_flags() -> impl Iterator { } fn supported(flag: &[u8]) -> Option<(&'static [u8], &'static Supported)> { - let entry = SPLICED - .iter() - .find(|f| f.names.iter().any(|n| *n == flag))?; + let entry = SPLICED.iter().find(|f| f.names.contains(&flag))?; Some((entry.canonical, &entry.kind)) } From ee4961b635fe1d9f65f10331dbf73b991152d1b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:48:44 +0000 Subject: [PATCH 13/15] NODE_OPTIONS: refuse a dash-prefixed value for a required-value flag Node's option parser treats a space-separated value that starts with a dash as a missing argument: NODE_OPTIONS="--require --import" fails with "--require requires an argument" and exit code 9. Bun took the next token unconditionally, so the same input tried to load a module named "--import". Node also strips one leading backslash from such a value, so "--title \-x" sets the title to "-x". Match both. The "requires an argument" error now names the flag as typed, including a trailing "=" for the inline form, like Node. Tests carried over from the preload-only implementation in #34101: the `bun run ` preload variants, NODE_OPTIONS preloads running before command-line preloads, --require running before --import regardless of declaration order, and --no-warnings applied from the environment. --- src/bun_core/node_options.rs | 24 +++++++++---- test/cli/env/node-options.test.ts | 57 +++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index b11bd37b3aca..7fc50569fd53 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -553,17 +553,24 @@ pub fn filter(raw: &[u8]) -> Vec> { out.push(arg); } Some((canonical, Supported::Value)) => { + // Errors name the flag as typed (`--import=`, `-r`), like Node. let value: &[u8] = match inline_value { Some(v) if !v.is_empty() => v, - Some(_) => fail_requires_argument(&normalized), - // Node's option parser takes the next token as the value - // unconditionally, even when it starts with a dash. + Some(_) => fail_requires_argument(&tok[..name.len() + 1]), + // Node's option parser refuses a space-separated value + // that starts with a dash (`--require --import` is a + // missing argument, not a module named "--import") and + // strips one leading backslash so `\-x` passes `-x`. None => match tokens.get(i) { - Some(next) => { + Some(next) if next.first() != Some(&b'-') => { i += 1; - next + if next.starts_with(b"\\-") { + &next[1..] + } else { + next + } } - None => fail_requires_argument(&normalized), + _ => fail_requires_argument(name), }, }; let mut arg = canonical.to_vec(); @@ -656,6 +663,11 @@ mod tests { toks(b"--max_http_header_size=65536"), vec![b"--max-http-header-size=65536".to_vec()], ); + // Inline values may start with a dash; a space-separated `\-x` is + // Node's escape for a literal `-x`. + assert_eq!(toks(b"--title=-x"), vec![b"--title=-x".to_vec()]); + assert_eq!(toks(br"--title \-x"), vec![b"--title=-x".to_vec()]); + assert_eq!(toks(br"--title \x"), vec![br"--title=\x".to_vec()]); } #[test] diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 5e533d5c7f25..a75e20646fed 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -53,6 +53,8 @@ describe("NODE_OPTIONS environment variable", () => { const preloadFixtures = { "pre.mjs": `globalThis.__PRELOADED = true;\n`, "app.mjs": `console.log(globalThis.__PRELOADED === true ? "PRELOADED" : "NOT_PRELOADED");\n`, + "A.mjs": `console.log("A");\n`, + "B.mjs": `console.log("B");\n`, }; describe.each([ @@ -67,6 +69,45 @@ describe("NODE_OPTIONS environment variable", () => { const { stdout, exitCode } = await run(String(dir), ["app.mjs"], opts); expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); }); + + test.concurrent("bun run ", async () => { + using dir = tempDir("node-options-preload-run", preloadFixtures); + const { stdout, exitCode } = await run(String(dir), ["run", "app.mjs"], opts); + expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); + }); + }); + + test.concurrent("NODE_OPTIONS preloads run before command-line preloads", async () => { + using dir = tempDir("node-options-order", preloadFixtures); + const { stdout, exitCode } = await run( + String(dir), + ["--import", "./B.mjs", "-e", "console.log('main')"], + "--import ./A.mjs", + ); + expect({ stdout, exitCode }).toEqual({ stdout: "A\nB\nmain\n", exitCode: 0 }); + }); + + test.concurrent("--require entries run before --import entries (Node parity)", async () => { + using dir = tempDir("node-options-multi", preloadFixtures); + // --import is declared first but --require runs first, as in Node and + // in `bun --import ... --require ...`. + const { stdout, exitCode } = await run( + String(dir), + ["-e", "console.log('main')"], + "--import ./B.mjs --require ./A.mjs", + ); + expect({ stdout, exitCode }).toEqual({ stdout: "A\nB\nmain\n", exitCode: 0 }); + }); + + test.concurrent("applies --no-warnings", async () => { + using dir = tempDir("node-options-no-warnings", {}); + const { stdout, stderr, exitCode } = await run( + String(dir), + ["-e", `process.emitWarning("from-test"); console.log("ok")`], + "--no-warnings", + ); + expect(stderr).not.toContain("from-test"); + expect({ stdout, exitCode }).toEqual({ stdout: "ok\n", exitCode: 0 }); }); test.concurrent("applies --title", async () => { @@ -85,6 +126,12 @@ describe("NODE_OPTIONS environment variable", () => { expect({ stdout, exitCode }).toEqual({ stdout: "ipv4first\n", exitCode: 0 }); }); + test.concurrent("a space-separated value may start with a dash only when escaped as \\-", async () => { + using dir = tempDir("node-options-escaped-dash", {}); + const { stdout, exitCode } = await run(String(dir), ["-e", "console.log(process.title)"], "--title \\-from-env"); + expect({ stdout, exitCode }).toEqual({ stdout: "-from-env\n", exitCode: 0 }); + }); + test.concurrent("command-line flags win over NODE_OPTIONS", async () => { using dir = tempDir("node-options-precedence", {}); const { stdout, exitCode } = await run( @@ -159,16 +206,22 @@ describe("NODE_OPTIONS environment variable", () => { expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); }); + // Node names the flag as typed. A space-separated value that starts with a + // dash is the next flag, not a value: `--require --import` must not try to + // load a module named "--import". test.each([ ["--import", "--import"], ["--import=", "--import="], ["--require=", "--require="], ["-r", "-r"], ["--conditions", "--conditions"], - ])("required value missing for %s is rejected with exit code 9", async (_name, opts) => { + ["--require --import ./pre.mjs", "--require"], + ["--conditions -C development", "--conditions"], + ["--title --", "--title"], + ])("required value missing for %s is rejected with exit code 9", async (opts, flag) => { using dir = tempDir("node-options-noval", preloadFixtures); const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], opts); - expect(stderr).toContain("requires an argument"); + expect(stderr).toContain(`${flag} requires an argument`); expect(stdout).toBe(""); expect(exitCode).toBe(9); }); From f0530bd963dc354619f458f3512e3c05f84fd484 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:54:49 +0000 Subject: [PATCH 14/15] NODE_OPTIONS: drop --expose-internals from the allowlist Node has never accepted --expose-internals in NODE_OPTIONS: every release exits with "--expose-internals is not allowed in NODE_OPTIONS" and status 9, and node v26.3.0 does not list it in process.allowedNodeEnvironmentFlags. With it in the allowlist Bun dropped the flag silently. Now it warns, the same as for --test and any other flag Node refuses. --- src/bun_core/node_options.rs | 3 ++- test/cli/env/node-options.test.ts | 17 +++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index 7fc50569fd53..5be58aa4a205 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -159,7 +159,6 @@ static ALLOWED: &[&[u8]] = &[ b"--experimental-webstorage", b"--experimental-worker", b"--expose-gc", - b"--expose-internals", b"--extra-info-on-fatal-exception", b"--force-async-hooks-checks", b"--force-context-aware", @@ -699,6 +698,8 @@ mod tests { assert!(!is_allowed(b"--definitely-not-a-real-flag")); assert!(!is_allowed(b"--eval")); assert!(!is_allowed(b"-e")); + assert!(!is_allowed(b"--expose-internals")); + assert!(!is_allowed(b"--test")); } #[test] diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index a75e20646fed..44f81333596b 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -162,12 +162,17 @@ describe("NODE_OPTIONS environment variable", () => { expect({ stdout, exitCode }).toEqual({ stdout: "SP\nmain\n", exitCode: 0 }); }); - test.concurrent("unknown flag warns but does not exit", async () => { - using dir = tempDir("node-options-unknown", preloadFixtures); - const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], "--definitely-not-a-real-flag"); - expect(stderr).toContain("--definitely-not-a-real-flag is not allowed in NODE_OPTIONS"); - expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); - }); + // `--expose-internals` and `--test` are real Node flags that Node refuses + // in NODE_OPTIONS (exit 9). Bun warns and continues, like for unknown flags. + test.each(["--definitely-not-a-real-flag", "--expose-internals", "--test"])( + "flag outside the allowlist (%s) warns but does not exit", + async flag => { + using dir = tempDir("node-options-unknown", preloadFixtures); + const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], flag); + expect(stderr).toContain(`${flag} is not allowed in NODE_OPTIONS`); + expect({ stdout, exitCode }).toEqual({ stdout: "NOT_PRELOADED\n", exitCode: 0 }); + }, + ); test.concurrent("disallowed flag (--eval) warns and does not evaluate", async () => { using dir = tempDir("node-options-eval", preloadFixtures); From a18574c310fe2b81abdfd1cae2a2ab9f2a173b47 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:31:51 +0000 Subject: [PATCH 15/15] NODE_OPTIONS: splice --use-openssl-ca and --use-bundled-ca, pin the value parser edges process.allowedNodeEnvironmentFlags lists --use-openssl-ca and --use-bundled-ca, and Arguments::parse applies them next to --use-system-ca, which was already spliced. Splice the other two as well so the CA store trio behaves the same from the environment. Document why the flags Bun reads from process.execArgv in JS (--tls-min-*, --tls-max-*, --stack-trace-limit, --trace-*) stay out of the table: injected tokens are hidden from execArgv, so a splice would not reach them. Tests now pin the value parser at its edges, each checked against node v26.3.0: the error names the flag as typed (--max_http_header_size, --max_http_header_size=), the backslash strip applies only to a space-separated value that starts with exactly "\-", an inline "=" value keeps its backslash, and a quoted "\-x" unescapes to "-x" and is refused as a dash-prefixed value. --- src/bun_core/node_options.rs | 13 ++++++++--- test/cli/env/node-options.test.ts | 37 +++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index 5be58aa4a205..f1062df1c08b 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -420,9 +420,14 @@ enum Supported { OptionalValue, } -/// The subset of Node's allowlist that Bun's CLI implements. Each entry maps -/// the accepted spellings (Node's short forms `-r` / `-C` included) to the -/// long spelling Bun declares in its param tables. +/// The subset of Node's allowlist that Bun's CLI applies while it parses argv +/// in Rust. Each entry maps the accepted spellings (Node's short forms `-r` / +/// `-C` included) to the long spelling Bun declares in its param tables. +/// +/// Flags Bun applies by reading `process.execArgv` in JS (`--tls-min-*`, +/// `--tls-max-*`, `--stack-trace-limit`, `--trace-*`) are not listed: the +/// injected tokens are hidden from `execArgv`, so a splice would not reach +/// their consumers. struct SplicedFlag { names: &'static [&'static [u8]], canonical: &'static [u8], @@ -450,6 +455,8 @@ static SPLICED: &[SplicedFlag] = &[ SplicedFlag { names: &[b"--throw-deprecation"], canonical: b"--throw-deprecation", kind: Supported::Flag }, SplicedFlag { names: &[b"--trace-deprecation"], canonical: b"--trace-deprecation", kind: Supported::Flag }, SplicedFlag { names: &[b"--trace-warnings"], canonical: b"--trace-warnings", kind: Supported::Flag }, + SplicedFlag { names: &[b"--use-bundled-ca"], canonical: b"--use-bundled-ca", kind: Supported::Flag }, + SplicedFlag { names: &[b"--use-openssl-ca"], canonical: b"--use-openssl-ca", kind: Supported::Flag }, SplicedFlag { names: &[b"--use-system-ca"], canonical: b"--use-system-ca", kind: Supported::Flag }, SplicedFlag { names: &[b"--zero-fill-buffers"], canonical: b"--zero-fill-buffers", kind: Supported::Flag }, SplicedFlag { names: &[b"--inspect"], canonical: b"--inspect", kind: Supported::OptionalValue }, diff --git a/test/cli/env/node-options.test.ts b/test/cli/env/node-options.test.ts index 44f81333596b..2bf553955968 100644 --- a/test/cli/env/node-options.test.ts +++ b/test/cli/env/node-options.test.ts @@ -126,10 +126,27 @@ describe("NODE_OPTIONS environment variable", () => { expect({ stdout, exitCode }).toEqual({ stdout: "ipv4first\n", exitCode: 0 }); }); - test.concurrent("a space-separated value may start with a dash only when escaped as \\-", async () => { - using dir = tempDir("node-options-escaped-dash", {}); - const { stdout, exitCode } = await run(String(dir), ["-e", "console.log(process.title)"], "--title \\-from-env"); - expect({ stdout, exitCode }).toEqual({ stdout: "-from-env\n", exitCode: 0 }); + // Node strips one backslash from a space-separated value that starts with + // `\-`, and only there: an inline `=` value keeps it, `\\-x` keeps both, and + // inside double quotes `\-` is the tokenizer's escape, so the value is `-x`. + describe.each([ + ["--title \\-x", "-x"], + ['--title "\\\\-x"', "-x"], + ["--title=\\-x", "\\-x"], + ["--title \\\\-x", "\\\\-x"], + ])("a value may start with a dash only when escaped: %s", (opts, title) => { + test.concurrent("sets the title", async () => { + using dir = tempDir("node-options-escaped-dash", {}); + const { stdout, exitCode } = await run(String(dir), ["-e", "console.log(process.title)"], opts); + expect({ stdout, exitCode }).toEqual({ stdout: `${title}\n`, exitCode: 0 }); + }); + }); + + test.concurrent("applies --use-openssl-ca (conflicts with a command-line CA flag, as on the CLI)", async () => { + using dir = tempDir("node-options-ca", {}); + const { stdout, stderr, exitCode } = await run(String(dir), ["--use-bundled-ca", "-e", "1"], "--use-openssl-ca"); + expect(stderr).toContain("choose exactly one of --use-system-ca, --use-openssl-ca, or --use-bundled-ca"); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 1 }); }); test.concurrent("command-line flags win over NODE_OPTIONS", async () => { @@ -211,18 +228,22 @@ describe("NODE_OPTIONS environment variable", () => { expect({ stdout, exitCode }).toEqual({ stdout: "PRELOADED\n", exitCode: 0 }); }); - // Node names the flag as typed. A space-separated value that starts with a - // dash is the next flag, not a value: `--require --import` must not try to - // load a module named "--import". + // Node names the flag as typed (underscores kept, trailing `=` kept). A + // space-separated value that starts with a dash is the next flag, not a + // value: `--require --import` must not try to load a module named + // "--import", and a quoted `"\-x"` unescapes to `-x` before that check. test.each([ ["--import", "--import"], ["--import=", "--import="], ["--require=", "--require="], ["-r", "-r"], ["--conditions", "--conditions"], + ["--max_http_header_size", "--max_http_header_size"], + ["--max_http_header_size=", "--max_http_header_size="], ["--require --import ./pre.mjs", "--require"], ["--conditions -C development", "--conditions"], ["--title --", "--title"], + ['--title "\\-x"', "--title"], ])("required value missing for %s is rejected with exit code 9", async (opts, flag) => { using dir = tempDir("node-options-noval", preloadFixtures); const { stdout, stderr, exitCode } = await run(String(dir), ["app.mjs"], opts); @@ -308,6 +329,8 @@ describe("NODE_OPTIONS environment variable", () => { "--throw-deprecation", "--trace-deprecation", "--trace-warnings", + "--use-bundled-ca", + "--use-openssl-ca", "--use-system-ca", "--zero-fill-buffers", "--inspect",