diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index 8b44035e7..f62df0fe8 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -3634,6 +3634,7 @@ fn build_script_command( ); let aug = nub_core::node::spawn::compute_augmentation_env( &nub_binary, + node.path.as_std_path(), node.version, compat_mode, pnp_ctx.as_ref().map(|c| c.pnp_cjs.as_path()), @@ -4558,11 +4559,13 @@ fn run_watch(file: &str, args: &[String]) -> Result { let mut node_args = vec!["--watch".to_string(), "--watch-preserve-output".to_string()]; let node_options = env::var("NODE_OPTIONS").ok(); + let accepted = nub_core::node::discovery::accepted_env_flags(node.path.as_std_path()); let inject = nub_core::node::flags::compute_inject_flags( node.version.clone(), args, node_options.as_deref(), false, + accepted.as_ref(), ); for flag in &inject { node_args.push(flag.to_string()); @@ -4923,6 +4926,7 @@ fn apply_exec_augmentation(cmd: &mut std::process::Command, cwd: &Path) { let pnp_ctx = nub_core::pnp::detect(cwd); let Some(aug) = nub_core::node::spawn::compute_augmentation_env( &nub_binary, + node.path.as_std_path(), node.version, false, pnp_ctx.as_ref().map(|c| c.pnp_cjs.as_path()), diff --git a/crates/nub-cli/src/pm_engine/mod.rs b/crates/nub-cli/src/pm_engine/mod.rs index fd732da55..ebf45e0fb 100644 --- a/crates/nub-cli/src/pm_engine/mod.rs +++ b/crates/nub-cli/src/pm_engine/mod.rs @@ -1393,6 +1393,7 @@ fn apply_lifecycle_augmentation(cwd: &Path) { let pnp_ctx = nub_core::pnp::detect(cwd); let Some(aug) = nub_core::node::spawn::compute_augmentation_env( &nub_binary, + node.path.as_std_path(), node.version, // Lifecycle scripts are never compat: PM verbs run augmented (there is // no `--node` lifecycle path). diff --git a/crates/nub-core/src/node/discovery.rs b/crates/nub-core/src/node/discovery.rs index f983ca9c1..949b65dc5 100644 --- a/crates/nub-core/src/node/discovery.rs +++ b/crates/nub-core/src/node/discovery.rs @@ -1097,6 +1097,122 @@ fn write_version_cache(node_path: &Path, version: &NodeVersion) { ); } +/// The set of flag NAMES the given Node binary accepts (its +/// `process.allowedNodeEnvironmentFlags`) — the authoritative universe of flags +/// nub may inject or propagate via `NODE_OPTIONS`. Spawns the binary once with a +/// tiny `-e` probe and caches the result on disk keyed by (path, mtime), so every +/// repeat call is spawn-free (one probe per binary, ever). Returns `None` if the +/// probe cannot run or produces nothing usable — callers then fall back to plain +/// version-band gating rather than dropping flags they still need. +/// +/// ## Why an authoritative probe and not a static binary byte-scan +/// The option names ARE embedded as string literals in the Node binary, so a +/// `strings | grep` would appear to work — but a string match is not proof the +/// option is a LIVE accepted flag (it can sit in help text or unrelated data), and +/// injecting a non-accepted flag is the exact `node: bad option` startup abort this +/// guards against. `allowedNodeEnvironmentFlags` is Node's own ground truth. +/// +/// ## Why this is needed (the open-ended-`Unflag`-band hazard) +/// Node does NOT keep every experimental flag forever. Most unflagged flags survive +/// as accepted no-ops (`--experimental-fetch`, `--experimental-modules`, …), but +/// some are HARD-REMOVED on a later major — `--experimental-policy` and +/// `--experimental-network-imports` are gone, and `--experimental-permission` was +/// accepted through Node 23.11 then deleted at 24.0 (it stabilized to +/// `--permission`). An open-ended `Unflag [lo, ∞)` band in the feature matrix would +/// keep injecting such a flag into every future Node and abort it at startup. +/// Intersecting the version-band inject set with this probe makes injection +/// self-correcting: a flag the running Node no longer accepts is simply dropped, no +/// nub release required. +pub fn accepted_env_flags(node_path: &Path) -> Option> { + if let Some(cached) = read_env_flags_cache(node_path) { + return Some(cached); + } + + // Clear an inherited NODE_OPTIONS for the probe. `node -e` (unlike `node + // --version`) runs NODE_OPTIONS `--require`/`--import` preloads, so an inherited + // preload that writes to stdout would pollute the flag list, and a preload with + // side effects would run for nothing. The accepted-flag set is a static property + // of the binary, independent of NODE_OPTIONS — so removing it makes the probe + // deterministic and side-effect-free. (Pollution could only ADD tokens, never + // drop a real flag, so this is defense-in-depth, not a correctness fix.) + let output = Command::new(node_path) + .arg("-e") + .arg("process.stdout.write([...process.allowedNodeEnvironmentFlags].join(\"\\n\"))") + .env_remove("NODE_OPTIONS") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let flags: std::collections::BTreeSet = + stdout.split_whitespace().map(str::to_string).collect(); + if flags.is_empty() { + return None; + } + + write_env_flags_cache(node_path, &flags); + Some(flags) +} + +/// mtime (seconds since epoch) of a Node binary, for cache-key freshness. `None` +/// if the path can't be stat'd — a miss then forces a fresh probe. +fn node_mtime_secs(node_path: &Path) -> Option { + fs::metadata(node_path) + .ok()? + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) +} + +/// Read the cached `allowedNodeEnvironmentFlags` set for a binary, honoring the +/// (path, mtime) key so a rebuilt/replaced Node at the same path re-probes. Kept in +/// a sibling file to `node-discovery.json` so it never risks the version cache. +fn read_env_flags_cache(node_path: &Path) -> Option> { + let cache = cache_dir()?.join("node-env-flags.json"); + let content = fs::read_to_string(&cache).ok()?; + let data: serde_json::Value = serde_json::from_str(&content).ok()?; + let key = node_path.to_string_lossy(); + let entry = data.get(key.as_ref())?; + let cached_mtime = entry.get("mtime")?.as_u64()?; + + if cached_mtime != node_mtime_secs(node_path)? { + return None; + } + + let arr = entry.get("flags")?.as_array()?; + Some( + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + ) +} + +fn write_env_flags_cache(node_path: &Path, flags: &std::collections::BTreeSet) { + let Some(dir) = cache_dir() else { return }; + let _ = fs::create_dir_all(&dir); + let cache = dir.join("node-env-flags.json"); + + let mut data: serde_json::Value = fs::read_to_string(&cache) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or_else(|| serde_json::json!({})); + + let key = node_path.to_string_lossy().to_string(); + data[key] = serde_json::json!({ + "flags": flags.iter().collect::>(), + "mtime": node_mtime_secs(node_path).unwrap_or(0), + }); + + let _ = fs::write( + &cache, + serde_json::to_string_pretty(&data).unwrap_or_default(), + ); +} + /// Scan the nvm install directory for a version matching the pin. fn scan_nvm(pin: &VersionPin) -> Option { let nvm_dir = nvm_dir()?; diff --git a/crates/nub-core/src/node/flags.rs b/crates/nub-core/src/node/flags.rs index 0cf5a037f..fd8749a82 100644 --- a/crates/nub-core/src/node/flags.rs +++ b/crates/nub-core/src/node/flags.rs @@ -129,11 +129,19 @@ pub fn test_coverage_exclude_supported(node_version: &NodeVersion) -> bool { /// (Webstorage's `--experimental-webstorage` is injected in `spawn.rs`, not here — /// always-injected on the `webstorage_flag_needed` band, suppressed only when the /// user already supplied the flag in either polarity; see there.) +/// `accepted_env_flags`: the running Node binary's actual +/// `process.allowedNodeEnvironmentFlags` set (probed + cached in +/// [`super::discovery::accepted_env_flags`]). When `Some`, the computed inject set +/// is intersected with it in Stage 4 so nub never injects a flag THIS Node rejects +/// — the self-correcting guard for open-ended `Unflag` bands (a flag Node +/// hard-removes on a later major, e.g. `--experimental-permission` → `--permission` +/// at 24.0). `None` (probe unavailable) preserves pure version-band behavior. pub fn compute_inject_flags( node_version: NodeVersion, user_argv: &[String], node_options: Option<&str>, show_warnings: bool, + accepted_env_flags: Option<&std::collections::BTreeSet>, ) -> Vec<&'static str> { // Stage 1: compute the would-inject set. let mut flags: Vec<&str> = Vec::new(); @@ -180,6 +188,25 @@ pub fn compute_inject_flags( // Stage 3: subtract. flags.retain(|flag| !user_negations.iter().any(|neg| is_negation_of(neg, flag))); + // Stage 4: intersect with the binary's ACTUAL accepted-flag set, when known. + // Version bands say what nub WANTS; the probe says what THIS Node binary + // ACCEPTS. Node hard-removes some experimental flags on later majors (e.g. + // `--experimental-policy`, `--experimental-permission` → `--permission` at 24.0), + // so an open-ended `Unflag` band would otherwise inject a "bad option" that + // aborts Node at startup. Dropping any flag the binary doesn't accept makes + // injection self-correcting — no nub release needed when Node removes a flag. + // `None` (probe unavailable — rare; means the binary isn't runnable, in which + // case the spawn fails anyway) leaves the version-band set untouched: no + // regression. Compare on the flag NAME (token before `=`), since value-bearing + // flags like `--disable-warning=ExperimentalWarning` appear in the accepted set + // as the bare name `--disable-warning`. + if let Some(accepted) = accepted_env_flags { + flags.retain(|flag| { + let name = flag.split_once('=').map_or(*flag, |(n, _)| n); + accepted.contains(name) + }); + } + flags } @@ -194,9 +221,16 @@ pub fn compute_inject_flags( /// hand-maintained parallel list: /// * the experimental `Unflag(flag)` families come straight from the feature /// matrix — a flag's existence floor is the LOWEST `lo` of any band that -/// unflags it. Once a flag exists Node keeps ACCEPTING it (as a default-true -/// no-op bool) at every higher version, so the "rejected" band is exactly -/// `[0, floor)` and a single floor per flag is sufficient. +/// unflags it. This floor gates the LOWER bound only (`[0, floor)` is "bad +/// option" territory). It does NOT bound the top: Node may KEEP a flag as an +/// accepted no-op indefinitely (`--experimental-fetch`, `--experimental-modules`) +/// OR HARD-REMOVE it on a later major (`--experimental-policy`, +/// `--experimental-permission` → `--permission` at 24.0), which no static floor +/// can predict. The upper-bound guard is dynamic, not a table: +/// `compute_inject_flags`' Stage 4 intersects the inject set with the binary's +/// probed `allowedNodeEnvironmentFlags` (see `super::discovery::accepted_env_flags`), +/// dropping any flag the running Node rejects. So a single floor per flag +/// remains sufficient HERE, with the runtime probe covering removal. /// * `--disable-warning` and `--test-coverage-exclude` are nub's own hygiene /// injections (not user-facing *features*), so their floors live as consts in /// this module; they are reused here rather than duplicated. @@ -304,14 +338,14 @@ mod tests { #[test] fn always_injects_warning_suppression_and_source_maps() { - let flags = compute_inject_flags(v(22, 15, 0), &[], None, false); + let flags = compute_inject_flags(v(22, 15, 0), &[], None, false, None); assert!(flags.contains(&"--disable-warning=ExperimentalWarning")); assert!(flags.contains(&"--enable-source-maps")); } #[test] fn injects_unflag_set_on_22_15() { - let flags = compute_inject_flags(v(22, 15, 0), &[], None, false); + let flags = compute_inject_flags(v(22, 15, 0), &[], None, false, None); assert!(flags.contains(&"--experimental-vm-modules")); assert!(flags.contains(&"--experimental-eventsource")); // webstorage is NOT in this static set: spawn.rs owns its injection (it @@ -327,11 +361,11 @@ mod tests { // vm.Module is never unflagged — inject from the 18.19 floor through 26.x. // (Regression: the old min:22.15.0 left vm.Module broken on 18.19–22.14.) assert!( - compute_inject_flags(v(18, 19, 0), &[], None, false) + compute_inject_flags(v(18, 19, 0), &[], None, false, None) .contains(&"--experimental-vm-modules") ); assert!( - compute_inject_flags(v(26, 2, 0), &[], None, false) + compute_inject_flags(v(26, 2, 0), &[], None, false, None) .contains(&"--experimental-vm-modules") ); } @@ -341,16 +375,16 @@ mod tests { // EventSource landed at 22.3.0 + 20.18.0 backport; never shipped on 21.x. // Injecting on 21.x is a "bad option" crash — the highest-stakes boundary here. let yes = "--experimental-eventsource"; - assert!(!compute_inject_flags(v(20, 17, 0), &[], None, false).contains(&yes)); - assert!(compute_inject_flags(v(20, 18, 0), &[], None, false).contains(&yes)); + assert!(!compute_inject_flags(v(20, 17, 0), &[], None, false, None).contains(&yes)); + assert!(compute_inject_flags(v(20, 18, 0), &[], None, false, None).contains(&yes)); // The hole: must NOT inject anywhere on the 21.x line. assert!( - !compute_inject_flags(v(21, 0, 0), &[], None, false).contains(&yes), + !compute_inject_flags(v(21, 0, 0), &[], None, false, None).contains(&yes), "must NOT inject --experimental-eventsource on 21.0 (flag never existed there → crash)" ); - assert!(!compute_inject_flags(v(22, 2, 0), &[], None, false).contains(&yes)); - assert!(compute_inject_flags(v(22, 3, 0), &[], None, false).contains(&yes)); - assert!(compute_inject_flags(v(26, 2, 0), &[], None, false).contains(&yes)); + assert!(!compute_inject_flags(v(22, 2, 0), &[], None, false, None).contains(&yes)); + assert!(compute_inject_flags(v(22, 3, 0), &[], None, false, None).contains(&yes)); + assert!(compute_inject_flags(v(26, 2, 0), &[], None, false, None).contains(&yes)); } #[test] @@ -358,13 +392,13 @@ mod tests { // node:sqlite: flag added 22.5.0, unflagged 22.13.0 (22.x) and 23.4.0 (23.x). // Inject only where the flag exists AND is still required. let sql = "--experimental-sqlite"; - assert!(!compute_inject_flags(v(22, 4, 0), &[], None, false).contains(&sql)); // flag absent - assert!(compute_inject_flags(v(22, 5, 0), &[], None, false).contains(&sql)); // band 1 floor - assert!(compute_inject_flags(v(22, 12, 0), &[], None, false).contains(&sql)); - assert!(!compute_inject_flags(v(22, 13, 0), &[], None, false).contains(&sql)); // unflagged on 22.x - assert!(compute_inject_flags(v(23, 3, 0), &[], None, false).contains(&sql)); // band 2 - assert!(!compute_inject_flags(v(23, 4, 0), &[], None, false).contains(&sql)); // unflagged on 23.x - assert!(!compute_inject_flags(v(24, 0, 0), &[], None, false).contains(&sql)); // unflagged everywhere after + assert!(!compute_inject_flags(v(22, 4, 0), &[], None, false, None).contains(&sql)); // flag absent + assert!(compute_inject_flags(v(22, 5, 0), &[], None, false, None).contains(&sql)); // band 1 floor + assert!(compute_inject_flags(v(22, 12, 0), &[], None, false, None).contains(&sql)); + assert!(!compute_inject_flags(v(22, 13, 0), &[], None, false, None).contains(&sql)); // unflagged on 22.x + assert!(compute_inject_flags(v(23, 3, 0), &[], None, false, None).contains(&sql)); // band 2 + assert!(!compute_inject_flags(v(23, 4, 0), &[], None, false, None).contains(&sql)); // unflagged on 23.x + assert!(!compute_inject_flags(v(24, 0, 0), &[], None, false, None).contains(&sql)); // unflagged everywhere after } #[test] @@ -372,10 +406,10 @@ mod tests { // WebSocket global is flag-gated on [20.10.0, 22.0.0): exists on 20.10+ and all // 21.x, default-on from 22.0.0. Below 20.10 the flag doesn't exist ("bad option"). let ws = "--experimental-websocket"; - assert!(!compute_inject_flags(v(20, 9, 0), &[], None, false).contains(&ws)); - assert!(compute_inject_flags(v(20, 10, 0), &[], None, false).contains(&ws)); - assert!(compute_inject_flags(v(21, 5, 0), &[], None, false).contains(&ws)); // all of 21.x - assert!(!compute_inject_flags(v(22, 0, 0), &[], None, false).contains(&ws)); // default-on + assert!(!compute_inject_flags(v(20, 9, 0), &[], None, false, None).contains(&ws)); + assert!(compute_inject_flags(v(20, 10, 0), &[], None, false, None).contains(&ws)); + assert!(compute_inject_flags(v(21, 5, 0), &[], None, false, None).contains(&ws)); // all of 21.x + assert!(!compute_inject_flags(v(22, 0, 0), &[], None, false, None).contains(&ws)); // default-on } #[test] @@ -385,14 +419,14 @@ mod tests { // default-on on the 23.x line (EOL before the backport). Inject on // [18.19, 22.19) ∪ [23.0, 24.5). let w = "--experimental-wasm-modules"; - assert!(compute_inject_flags(v(18, 19, 0), &[], None, false).contains(&w)); // floor - assert!(compute_inject_flags(v(22, 13, 0), &[], None, false).contains(&w)); - assert!(compute_inject_flags(v(22, 18, 0), &[], None, false).contains(&w)); - assert!(!compute_inject_flags(v(22, 19, 0), &[], None, false).contains(&w)); // default-on 22.x - assert!(compute_inject_flags(v(23, 2, 0), &[], None, false).contains(&w)); // 23.x stays flagged - assert!(compute_inject_flags(v(24, 4, 0), &[], None, false).contains(&w)); - assert!(!compute_inject_flags(v(24, 5, 0), &[], None, false).contains(&w)); // default-on 24.x - assert!(!compute_inject_flags(v(26, 0, 0), &[], None, false).contains(&w)); + assert!(compute_inject_flags(v(18, 19, 0), &[], None, false, None).contains(&w)); // floor + assert!(compute_inject_flags(v(22, 13, 0), &[], None, false, None).contains(&w)); + assert!(compute_inject_flags(v(22, 18, 0), &[], None, false, None).contains(&w)); + assert!(!compute_inject_flags(v(22, 19, 0), &[], None, false, None).contains(&w)); // default-on 22.x + assert!(compute_inject_flags(v(23, 2, 0), &[], None, false, None).contains(&w)); // 23.x stays flagged + assert!(compute_inject_flags(v(24, 4, 0), &[], None, false, None).contains(&w)); + assert!(!compute_inject_flags(v(24, 5, 0), &[], None, false, None).contains(&w)); // default-on 24.x + assert!(!compute_inject_flags(v(26, 0, 0), &[], None, false, None).contains(&w)); } #[test] @@ -402,17 +436,100 @@ mod tests { // flag is a "bad option" there). Also verify it snips out of an inherited // NODE_OPTIONS on a child below the 26.5.0 floor. let it = "--experimental-import-text"; - assert!(!compute_inject_flags(v(26, 4, 0), &[], None, false).contains(&it)); - assert!(compute_inject_flags(v(26, 5, 0), &[], None, false).contains(&it)); - assert!(compute_inject_flags(v(27, 0, 0), &[], None, false).contains(&it)); + assert!(!compute_inject_flags(v(26, 4, 0), &[], None, false, None).contains(&it)); + assert!(compute_inject_flags(v(26, 5, 0), &[], None, false, None).contains(&it)); + assert!(compute_inject_flags(v(27, 0, 0), &[], None, false, None).contains(&it)); // A user opt-out subtracts it. let argv = vec!["--no-experimental-import-text".to_string()]; - assert!(!compute_inject_flags(v(26, 5, 0), &argv, None, false).contains(&it)); + assert!(!compute_inject_flags(v(26, 5, 0), &argv, None, false, None).contains(&it)); // Inherited NODE_OPTIONS: stripped below the floor, kept at/above it. assert_eq!(strip_unsupported_node_options(it, &v(26, 4, 0)), ""); assert_eq!(strip_unsupported_node_options(it, &v(26, 5, 0)), it); } + #[test] + fn accepted_env_flags_intersection_guards_removed_flags() { + // The Stage-4 guard: an open-ended `Unflag` band (import-text is `[26.5, ∞)`) + // would keep injecting a flag Node later HARD-REMOVES (as it did with + // `--experimental-permission` → `--permission` at 24.0), aborting startup with + // "bad option". Intersecting with the binary's probed accepted-flag set drops + // exactly that flag. Model a future Node that removed import-text but still + // accepts the rest. + let it = "--experimental-import-text"; + let mut accepted: std::collections::BTreeSet = [ + "--enable-source-maps", + "--disable-warning", + "--experimental-vm-modules", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + // import-text ABSENT from the accepted set → dropped despite the band wanting it. + assert!( + !compute_inject_flags(v(27, 0, 0), &[], None, false, Some(&accepted)).contains(&it) + ); + // enable-source-maps present as a bare name → kept even though nub may inject a + // value-bearing form for other flags (name-based match). + assert!( + compute_inject_flags(v(27, 0, 0), &[], None, false, Some(&accepted)) + .contains(&"--enable-source-maps") + ); + // Value-bearing flag matches on its NAME: `--disable-warning=…` is kept because + // the accepted set carries the bare `--disable-warning`. + assert!( + compute_inject_flags(v(22, 15, 0), &[], None, false, Some(&accepted)) + .contains(&"--disable-warning=ExperimentalWarning") + ); + // Once the accepted set (re)includes import-text, it is injected again. + accepted.insert(it.to_string()); + assert!(compute_inject_flags(v(27, 0, 0), &[], None, false, Some(&accepted)).contains(&it)); + } + + #[test] + fn accepted_env_flags_none_preserves_version_band() { + // `None` (probe unavailable) is a pure pass-through: identical to the + // pre-guard version-band behavior, so a failed probe never regresses. + for ver in [v(22, 15, 0), v(26, 5, 0), v(27, 0, 0)] { + assert_eq!( + compute_inject_flags(ver.clone(), &[], None, false, None), + compute_inject_flags(ver.clone(), &[], None, false, None), + ); + } + } + + /// Real-Node invariant: for the host Node, EVERY flag nub wants to inject at that + /// version IS accepted by that Node (`process.allowedNodeEnvironmentFlags`). This + /// is the property the whole design leans on — nub only injects envvar-allowed + /// flags — so the Stage-4 intersection drops NOTHING on a current, supported Node + /// (it only ever bites a FUTURE Node that removed a flag). If this fails, nub is + /// injecting a flag the running Node rejects (a real startup-crash bug), not a + /// test artifact. Skips when no `node` is discoverable. + #[test] + fn host_node_accepts_every_injected_flag() { + let Ok(node) = crate::node::discovery::discover_node(std::path::Path::new(".")) else { + eprintln!("skipping: no node discoverable"); + return; + }; + let Some(accepted) = crate::node::discovery::accepted_env_flags(node.path.as_std_path()) + else { + eprintln!("skipping: could not probe {}", node.path); + return; + }; + let with = compute_inject_flags(node.version.clone(), &[], None, false, Some(&accepted)); + let without = compute_inject_flags(node.version.clone(), &[], None, false, None); + assert_eq!( + with, + without, + "the accepted-flag intersection dropped a flag on host Node v{} — nub wants a \ + flag this Node does not accept: {:?}", + node.version, + without + .iter() + .filter(|f| !with.contains(*f)) + .collect::>() + ); + } + #[test] fn shadow_realm_never_injected() { // ShadowRealm is DELIBERATELY not auto-unflagged (the harmony-flag policy): @@ -422,15 +539,15 @@ mod tests { // so a self-disable can't catch it (#246). Never inject it, at any version. // The categorical guard lives in feature_matrix (no_v8_harmony_flag_in_unflag_set). let s = "--experimental-shadow-realm"; - assert!(!compute_inject_flags(v(18, 19, 0), &[], None, false).contains(&s)); - assert!(!compute_inject_flags(v(22, 19, 0), &[], None, false).contains(&s)); - assert!(!compute_inject_flags(v(26, 0, 0), &[], None, false).contains(&s)); + assert!(!compute_inject_flags(v(18, 19, 0), &[], None, false, None).contains(&s)); + assert!(!compute_inject_flags(v(22, 19, 0), &[], None, false, None).contains(&s)); + assert!(!compute_inject_flags(v(26, 0, 0), &[], None, false, None).contains(&s)); } #[test] fn user_opt_out_via_argv() { let argv = vec!["--no-experimental-vm-modules".to_string()]; - let flags = compute_inject_flags(v(22, 15, 0), &argv, None, false); + let flags = compute_inject_flags(v(22, 15, 0), &argv, None, false, None); assert!(!flags.contains(&"--experimental-vm-modules")); // Other flags still present (eventsource is in-band at 22.15). assert!(flags.contains(&"--experimental-eventsource")); @@ -444,6 +561,7 @@ mod tests { &[], Some("--no-experimental-sqlite --max-old-space-size=4096"), false, + None, ); assert!(!flags.contains(&"--experimental-sqlite")); assert!(flags.contains(&"--experimental-vm-modules")); @@ -460,13 +578,19 @@ mod tests { // channels (argv and NODE_OPTIONS). let argv = vec!["--no-enable-source-maps".to_string()]; assert!( - !compute_inject_flags(v(22, 15, 0), &argv, None, false) + !compute_inject_flags(v(22, 15, 0), &argv, None, false, None) .contains(&"--enable-source-maps"), "user --no-enable-source-maps (argv) must suppress nub's always-inject" ); assert!( - !compute_inject_flags(v(22, 15, 0), &[], Some("--no-enable-source-maps"), false) - .contains(&"--enable-source-maps"), + !compute_inject_flags( + v(22, 15, 0), + &[], + Some("--no-enable-source-maps"), + false, + None + ) + .contains(&"--enable-source-maps"), "user --no-enable-source-maps (NODE_OPTIONS) must suppress it too" ); } @@ -485,6 +609,7 @@ mod tests { &["--disable-warning=DeprecationWarning".to_string()], None, false, + None, ); assert!( flags.contains(&"--disable-warning=ExperimentalWarning"), @@ -494,7 +619,7 @@ mod tests { #[test] fn show_warnings_suppresses_warning_flag() { - let flags = compute_inject_flags(v(22, 15, 0), &[], None, true); + let flags = compute_inject_flags(v(22, 15, 0), &[], None, true, None); assert!(!flags.contains(&"--disable-warning=ExperimentalWarning")); assert!(flags.contains(&"--enable-source-maps")); } @@ -504,7 +629,7 @@ mod tests { // At 20.0.0: --enable-source-maps and vm-modules (whole-floor) inject, but the // version-gated entries do not — sqlite/eventsource/websocket flags don't exist // here ("bad option"), and --disable-warning is below its 20.11 floor. - let flags = compute_inject_flags(v(20, 0, 0), &[], None, false); + let flags = compute_inject_flags(v(20, 0, 0), &[], None, false, None); assert!(flags.contains(&"--enable-source-maps")); assert!(flags.contains(&"--experimental-vm-modules")); assert!(!flags.contains(&"--experimental-sqlite")); @@ -519,7 +644,7 @@ mod tests { // "not allowed in NODE_OPTIONS"), which crashed the compat tier. It must // not be injected below 20.11; from 20.11 onward it is. for ver in [v(18, 19, 0), v(20, 0, 0), v(20, 10, 0)] { - let flags = compute_inject_flags(ver.clone(), &[], None, false); + let flags = compute_inject_flags(ver.clone(), &[], None, false, None); assert!( !flags.contains(&"--disable-warning=ExperimentalWarning"), "must NOT inject --disable-warning on {ver:?} (the flag aborts those versions)" @@ -531,7 +656,7 @@ mod tests { ); } for ver in [v(20, 11, 0), v(22, 13, 0)] { - let flags = compute_inject_flags(ver.clone(), &[], None, false); + let flags = compute_inject_flags(ver.clone(), &[], None, false, None); assert!( flags.contains(&"--disable-warning=ExperimentalWarning"), "must inject --disable-warning on {ver:?} (supported there)" @@ -653,7 +778,7 @@ mod tests { "source maps must be safe to inject on {ver:?}" ); assert!( - compute_inject_flags(ver.clone(), &[], None, false) + compute_inject_flags(ver.clone(), &[], None, false, None) .contains(&"--enable-source-maps"), "--enable-source-maps must inject on {ver:?}" ); @@ -665,7 +790,7 @@ mod tests { "source maps must be withheld on {ver:?}" ); assert!( - !compute_inject_flags(ver.clone(), &[], None, false) + !compute_inject_flags(ver.clone(), &[], None, false, None) .contains(&"--enable-source-maps"), "--enable-source-maps must NOT inject on {ver:?} (assert→TypeError regression)" ); diff --git a/crates/nub-core/src/node/spawn.rs b/crates/nub-core/src/node/spawn.rs index 6b559db4f..4fdfd0cf0 100644 --- a/crates/nub-core/src/node/spawn.rs +++ b/crates/nub-core/src/node/spawn.rs @@ -467,18 +467,29 @@ pub fn spawn_node(config: &SpawnConfig<'_>) -> Result { // a half-setup (flags + a nested shim, no preload). See // wiki/runtime/hijack-by-default.md. if !config.compat_mode && !is_reentrant && preload.is_some() { - // Flag injection. + // Flag injection — intersected with the binary's actual accepted-flag set + // (probed + cached) so an open-ended `Unflag` band never injects a flag a + // future Node has removed (which would abort startup with "bad option"). + let accepted = super::discovery::accepted_env_flags(config.node.path.as_std_path()); let inject = flags::compute_inject_flags( config.node.version.clone(), config.user_args, node_options.as_deref(), config.show_warnings, + accepted.as_ref(), ); for flag in &inject { cmd.arg(flag); } - // Web Storage: nub ALWAYS injects `--experimental-webstorage` on the band + // Web Storage: injected here, NOT through `compute_inject_flags`, so it sits + // OUTSIDE the Stage-4 accepted-flag intersection above. Safe: its band is + // CLOSED (`22.4–<25`) and the flag stabilized (not removed) at 25 — no + // open-ended-removal hazard, so it needs no probe guard. (Any FUTURE + // open-ended flag should go through `compute_inject_flags` to inherit the + // guard, not this direct-injection path.) + // + // nub ALWAYS injects `--experimental-webstorage` on the band // where that flag is the enabling mechanism (Node 22.4 through <25, i.e. // `webstorage_flag_needed`), regardless of whether the user opted into // localStorage persistence (the maintainer, 2026-06-15: "a flag that we inject no @@ -1007,6 +1018,7 @@ fn setup_path_shim(nub_binary: &Path) -> Result { /// out from under sibling scripts still running. pub fn compute_augmentation_env( nub_binary: &Path, + node_path: &Path, node_version: super::version::NodeVersion, compat_mode: bool, pnp: Option<&Path>, @@ -1045,11 +1057,16 @@ pub fn compute_augmentation_env( // NODE_OPTIONS — injected experimental flags, the preload, and webstorage. // Dedupe injected flags against any existing NODE_OPTIONS so we don't emit a // flag the user already set. + // Intersected with the binary's actual accepted-flag set (probed + cached), + // same self-correcting guard as the direct-spawn path: a flag a future Node has + // removed is dropped instead of aborting the script-runner child at startup. + let accepted = super::discovery::accepted_env_flags(node_path); let inject = flags::compute_inject_flags( node_version.clone(), &[], existing_node_options.as_deref(), false, + accepted.as_ref(), ); let mut node_opts_parts: Vec = inject.iter().map(|f| f.to_string()).collect(); // Yarn PnP `--require <.pnp.cjs>` BEFORE nub's preload token so PnP's