Skip to content

run, install: honor --no-env-file / --env-file for scripts run through the bun exec hop - #38182

Open
robobun wants to merge 1 commit into
mainfrom
farm/15e62043/filter-run-no-env-file
Open

robobun wants to merge 1 commit into
mainfrom
farm/15e62043/filter-run-no-env-file

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun has three places that spawn a package.json script through a bun exec "<script>" hop instead of sh -c "<script>": bun run --filter (src/runtime/cli/filter_run.rs), bun run --parallel / --sequential (src/runtime/cli/multi_run.rs), and bun install lifecycle scripts (src/install/lifecycle_script_runner.rs). The first two take the hop on every Windows spawn; lifecycle scripts take it on Windows and on POSIX systems with no shell.
  • bun exec is an ordinary runtime command: src/runtime/cli/exec_command.rs:54 runs the default .env* auto-load in its cwd (the package directory) on top of the envp it was handed, and only a --no-env-file on its own argv turns that off.
  • Result, wherever the hop is taken:
    • scripts see the package directory's .env / .env.<NODE_ENV> / .env.local; for bun install that includes every workspace member's own .env reaching its postinstall;
    • bun run --no-env-file --filter ... still exposes them, and --env-file X gets the default files layered in underneath;
    • a script such as NODE_ENV=production bun start is pre-seeded with the .env.development values picked by the runner's NODE_ENV, so the bun it starts cannot read .env.production. This is NODE_ENV=production in package.json scripts no longer reads from .env.production #9635, which alternate approach to env fix #9689 fixed for the script runner; the hop reintroduces it.
  • Plain bun run <script> on Windows is not affected: it runs the script in-process with the runner's env map, which deliberately skips the default files (run_command.rs:674). test/cli/run/env.test.ts ("does not pass variables from .env files into scripts") pins that for both shells on every platform; the three hop sites were the only paths that did not follow it.
bun 1.4.0 on Windows x64 (fixtures from the tests; Linux prints an empty value in every case)
> bun run --filter app show-env
app show-env: ENV_FILE_NAME=.env.local CUSTOM= INHERITED=inherited
> bun run --no-env-file --filter app show-env
app show-env: ENV_FILE_NAME=.env.local CUSTOM= INHERITED=inherited
> bun run --env-file packages/app/.env.custom --filter app show-env
app show-env: ENV_FILE_NAME=.env.local CUSTOM=custom INHERITED=inherited
> cd packages/app && bun run --parallel show-env
show-env | ENV_FILE_NAME=.env.local CUSTOM= INHERITED=inherited
> bun run show-env
ENV_FILE_NAME= CUSTOM= INHERITED=inherited

> bun install        (workspace member pkg1 has .env with PKG_DOTENV=leaked and a postinstall that echoes it)
packages/pkg1/seen.txt: PKG_DOTENV=leaked INHERITED=from-install

Fix

  • New ScriptArgv in lifecycle_script_runner.rs (next to replace_package_manager_run, the helper these same three callers already share; bun_install is the lower crate) builds the argv: <shell> -c <script> when the caller found a POSIX shell, otherwise bun exec --no-env-file <script>. All three spawn sites use it, so the rule lives in one place.
  • Why --no-env-file is right: at every site the envp passed to the spawn already is the environment the runner or installer decided on (process env plus any --env-file files for bun run; the installer's script env for lifecycle scripts). The hop exists only to be the shell, the role sh plays on POSIX, so it must add nothing; --no-env-file is the existing flag that makes bun exec use envp alone. --env-file values still arrive because they are in envp; a nested NODE_ENV=production bun reads its own files because nothing was pre-seeded. bun exec already accepts the flag (BASE_PARAMS_, src/runtime/cli/Arguments.rs:98) and the script stays positionals[1].
  • filter_run.rs / multi_run.rs now hold Option<&ZStr> for the shell (None on Windows) instead of resolving bun's own path as a fake shell up front; the helper resolves it when building argv, as the lifecycle runner always did. POSIX behavior is unchanged: both still fail with MissingShell when no shell is found.
  • Overlap: install: honor --env-file / --no-env-file and NODE_ENV for .env loading #36672 (install env flags) carries the same --no-env-file for the lifecycle hop; whichever lands second has a small conflict in that one hunk.
  • Verified:
    • test/cli/run/filter-workspace.test.ts: new describe.each over --filter and run --parallel, four cases each: defaults not loaded, --no-env-file, --env-file (requested file and inherited variables arrive, defaults do not), and the NODE_ENV=production in package.json scripts no longer reads from .env.production #9635 shape (script sets NODE_ENV=production, must see .env.production). Windows x64: 8 fail with bun 1.4.0 (ENV_FILE_NAME=.env.development in every case), 8 pass with this branch.
    • test/cli/install/bun-install-lifecycle-scripts.test.ts: a workspace member's postinstall must not see the member's .env while an inherited variable still arrives. Windows x64: fails with bun 1.4.0 (PKG_DOTENV=leaked), passes with this branch.
    • On Linux these cases pass before and after (a shell is always found there, so the hop is never taken); they pin the contract. The fail-before for this change is Windows-only by construction.
    • Also green on this branch: filter-workspace.test.ts, multi-run.test.ts, and bun-install-lifecycle-scripts.test.ts on Linux and Windows x64; run-quote.test.ts and no-orphans.test.ts on Windows x64.

Background

  • Default .env* loading: dot_env::Loader::load (src/dotenv/env_loader.rs:649) reads .env, .env.<suffix> (suffix from NODE_ENV) and .env.local from a directory unless asked to skip the defaults; --no-env-file asks for that skip; files named with --env-file are loaded either way. Values from these files never override variables already present in the process environment.
  • Script runner rule: RunCommand::configure_env_for_run loads the process env and the --env-file files and skips the defaults, so a .env* value reaches a script only through a bun the script itself starts, which reads the files for the script's own NODE_ENV. Because process env wins over files, anything pre-seeded into the script's environment is something that nested bun can no longer correct; that is what NODE_ENV=production in package.json scripts no longer reads from .env.production #9635 was.
  • The hop: there is no sh on Windows, so these runners use bun itself as the shell by spawning bun exec <script>, which interprets the script with Bun's shell. Unlike sh, bun exec runs the runtime's env setup in its cwd before interpreting anything. find_shell returns cmd.exe on Windows, which is why the helper keys the shell form on cfg!(unix) as the lifecycle runner did before.
  • envp: the environment block passed to the spawned process. filter_run / multi_run rebuild it per script from the runner's env map with PATH adjusted per package; the lifecycle runner builds it once per script chain from the installer's env.

…e.json scripts

`bun run --filter`, `bun run --parallel` / `--sequential`, and `bun install`
lifecycle scripts spawn each script as `bun exec <script>` when there is no
POSIX shell to use (always on Windows). `bun exec` is a regular runtime
command, so it also ran the default .env* auto-load in the script's
directory on top of the envp the runner had already built. Scripts saw the
package directory's .env files, --no-env-file and --env-file on the runner
had no effect on them, and a script that sets its own NODE_ENV was
pre-seeded with the wrong .env.<suffix> values (#9635). `sh -c` and the
in-process shell used by plain `bun run <script>` only get envp.

Build the argv in one shared helper, ScriptArgv, and pass --no-env-file to
the hop there so envp is the script's whole environment at all three sites.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cadac6ce-735c-484e-b8b5-d1b5286bd79a

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 68935d1.

📒 Files selected for processing (5)
  • src/install/lifecycle_script_runner.rs
  • src/runtime/cli/filter_run.rs
  • src/runtime/cli/multi_run.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts
  • test/cli/run/filter-workspace.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:31 AM PT - Aug 13th, 2026

@robobun, your commit 68935d1 is building: #94684

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed (68935d1), waiting on CI.

Reproduced on Windows x64 with bun 1.4.0: bun run --filter app show-env, bun run --parallel show-env (also with --no-env-file / --env-file) and a workspace member's postinstall all print the value from the package directory's .env; plain bun run show-env prints nothing, and every variant prints nothing on Linux. The new cases in test/cli/run/filter-workspace.test.ts (8) and test/cli/install/bun-install-lifecycle-scripts.test.ts (1) fail on that build and pass with this branch's debug build on Windows x64; on Linux the hop is never taken, so they pass before and after there.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: honor --env-file / --no-env-file and NODE_ENV for .env loading #36672 - Makes the same edit to the lifecycle-script bun exec hop in src/install/lifecycle_script_runner.rs, growing the argv array to insert --no-env-file for the same reason, so the install half of this PR is already covered there.

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes user-visible env-loading behavior across three spawn paths (--filter, --parallel/--sequential, and install lifecycle scripts) and the fail-before is Windows-only, a human look is still worthwhile.

What was reviewed:

  • ScriptArgv lifetime/pointer soundness — self_exe_path() is &'static, PhantomData<&'a ZStr> ties borrowed script/shell strings, and the 5-slot array with early null for the shell form matches spawn_process's *const *const c_char contract.
  • bun exec arg parsing — --no-env-file is a BASE_PARAMS_ flag consumed before positionals, so positionals[1] remains the script; exec_command.rs reads disable_default_env_files and passes it to run_env_loader.
  • POSIX path unchanged — both runners still fail MissingShell when no shell is found; the Some(shell) if cfg!(unix) guard preserves the lifecycle runner's existing cmd.exe avoidance.
Extended reasoning...

Overview

The PR fixes a Windows-specific (and shell-less POSIX) leak where bun exec, used as the shell hop for bun run --filter, bun run --parallel/--sequential, and bun install lifecycle scripts, auto-loaded the package directory's .env* files on top of the envp the runner had already built. The fix adds --no-env-file to that hop's argv and consolidates the three copy-pasted argv-building blocks into a shared ScriptArgv helper in lifecycle_script_runner.rs (the lower crate all three already depend on for replace_package_manager_run). filter_run.rs and multi_run.rs now hold Option<&'static ZStr> for the shell instead of resolving bun's own path as a fake shell up front.

Security risks

None identified. The change reduces environment surface: lifecycle scripts on Windows no longer implicitly receive workspace-member .env values they weren't getting on POSIX. No new input parsing, no path handling changes.

Level of scrutiny

Medium-high. The mechanism is a one-flag argv change plus a clean refactor, and the PR description traces the mechanism precisely (verified exec_command.rs:53-54 reads disable_default_env_files, Arguments.rs:98 defines the flag, self_exe_path() is &'static, spawn_process takes *const *const c_char). But it's a user-visible behavioral change on Windows across three subsystems, aligns those paths with the existing bun run <script> contract (which env.test.ts already pins), and the fail-before can only be observed on Windows — Linux CI passes before and after by construction. That combination warrants a human confirming the intended semantics before merge.

Other factors

  • Tests cover the full matrix: both runner entry points × {default, --no-env-file, --env-file, #9635 NODE_ENV shape}, plus a lifecycle-script case with an inherited-var positive control. All test.concurrent, hermetic, and use tempDir.
  • The refactor removes ~40 lines of duplicated argv construction and the Option<*const c_char> layout comment (now encoded once in ScriptArgv's doc).
  • ZStr::from_slice_with_nul replaces from_raw_mut in the lifecycle runner — same semantics, cleaner call.
  • CI (#94684) was still building at the time of this review; Windows lanes are the ones that actually exercise the fix.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant