Compose the env object passed to every child process. Implements the isolated env policy: tasks see only the essentials allowlist plus what the user explicitly declared.
export interface BuildEnvOptions {
passThrough: readonly string[] // names → values from source
define: Readonly<Record<string, string>> // explicit literal pairs
source: NodeJS.ProcessEnv // typically process.env
binPaths?: readonly string[] // prepended to PATH (project bins)
}
export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnvLayers, lowest to highest priority:
-
Essential allowlist — hard-coded set of env vars copied from
sourcewhen present. The list:POSIX:
PATH,HOME,SHELL,USER,LOGNAME,TMPDIR,TEMP,TMP,LANG,LC_ALL,LC_CTYPE,TERM,COLORTERM,FORCE_COLOR,NO_COLOR,CI,NODE_OPTIONS.Windows:
SYSTEMROOT,APPDATA,LOCALAPPDATA,PROGRAMDATA,PROGRAMFILES,PROGRAMFILES(X86),COMSPEC,PATHEXT. -
passThroughnames — for each name, copy its value fromsourceif present. Missing names are skipped (not assigned to empty string). -
defineentries — literalname: valuepairs, applied next so they override earlier layers (includingPATHif you want, though most users won't). -
binPathsPATH prefix — applied last, AFTERdefine. Each entry is prepended to the existingPATH(joined bypath.delimiter). The orchestrator passes[<projectDir>/node_modules/.bin]so local tools resolve withoutnpx. Only the project's own bin — never the workspace root's or sibling projects' — so project isolation holds.
Result: a NodeJS.ProcessEnv ready to pass to Bun.spawn.
- Doesn't validate that names look reasonable (no
=in names, etc.). Garbage in → garbage out. - Doesn't read from
.envfiles or anywhere exceptsource. If you want.envsupport, do it at the config-author level (parse the file invx.config.tsand feed values intodefine). - Doesn't merge
PATHsmart-style (prepend project bins, etc.). Pure override.node_modules/.binis on PATH because the shell handles it (or doesn't); we don't intervene. - Doesn't strip or sanitize values. Whatever's in
source[name]is what the child sees.
Without the allowlist, child processes inherit the full parent env, which:
- Makes builds non-reproducible across machines (every CI flag, every user-installed dotfile thing leaks in).
- Pollutes the cache key (if the user mistakenly tracks env:
*). - Hides what a task actually depends on.
With it, the rule is simple: a task depends on whatever its config declares, plus enough essentials to find binaries and behave normally.
The allowlist is intentionally not configurable at the task level.
Workspace-level customization (extending the list) is a possible
future feature; for now, declare via passThrough what you need.
env.test.ts covers:
- Essentials passed from source; undeclared vars stripped.
- Essentials omitted when not present in source.
passThroughnames forwarded; missing names absent (not empty string) in result.definevalues applied.defineoverridespassThrough.defineoverrides essentials.
Adjusting policy:
- Different essentials list — edit
ESSENTIAL_ENV. BumpCACHE_VERSIONif you think this changes task identity (technically it changes what the child sees, but not the cache key directly — env values participate viacache.inputs.env, not via the essential list). - No essentials at all — start with
{}, only applypassThroughdefine. Most tools won't findPATH; user must declare it. Strict reproducibility at high friction cost.
- Layered profiles — workspace-level + task-level merging. Build
a different
BuildEnvOptionsupstream and feed it through; this module stays simple.