fix: prevent fork-bomb during packageManager-driven version switching (#11346)

* fix: prevent fork-bomb during packageManager-driven version switching

When pnpm was installed via one method (e.g. `npm install -g pnpm@A`)
and run in a project whose package.json's packageManager field selected
a different pnpm version (pnpm@B), and a pnpm-workspace.yaml existed at
the project root, the install-child spawned by `installPnpmToTools` to
fetch pnpm@B inherited a cwd under the pnpm home directory. pnpm's
workspace walk-up from there discovered the ancestor pnpm-workspace.yaml,
adopted the root package.json, and re-triggered switchCliVersion inside
the child. Because the target tool dir had not yet been symlinked in, the
recursive installPnpmToTools call saw alreadyExisted === false and kicked
off another nested install, recursing forever at 100% CPU.

Force the install-child's environment to disable its own version handling:
- `npm_config_manage_package_manager_versions=false` (v10 setting name)
- `pnpm_config_pm_on_fail=ignore` (v11+ setting name)

Also set the v11 setting on the final spawn at the end of switchCliVersion,
so when v10 hands off to a v11 target the child's check/download paths stay
disabled regardless of which env-var convention the child reads.

Closes #11337.

* test: add v11-switch and same-version regression tests for #11337

- v11 switch with a root pnpm-workspace.yaml: covers the primary #11337
  reproducer (target major differs from running major). Before the fix this
  fork-bombed via the install-child's workspace walk-up; now it reaches the
  terminal spawn and `installPnpmToTools` completes.
- Same-version short-circuit: with a root pnpm-workspace.yaml and
  `packageManager: pnpm@<current>`, `switchCliVersion` must return at the
  `pm.version === packageManager.version` guard, and the tool dir must not
  be created. Guards against a future regression where the ancestor
  pnpm-workspace.yaml alone accidentally triggers an install.

* fix(installPnpmToTools): isolate the install-child from the caller's workspace

Pass `--ignore-workspace` to the child pnpm so it doesn't walk up from the
stage directory and adopt the caller's pnpm-workspace.yaml as its own root.
That walk-up was both (a) the mechanism that caused the #11337 fork-bomb
(the child would rediscover the caller's packageManager field and re-enter
switchCliVersion) and (b) a correctness problem in its own right: once the
child treats the caller's project as its workspace, `pnpm add` runs with
semantics that don't match an isolated tool-dir install. The env-var guards
from the previous commit stay in place as a defense-in-depth measure in
case any future code path surfaces a wantedPackageManager without going
through workspace discovery.

Also fold the new v11-switch regression into the existing v11 test rather
than adding a second v11 install, so CI doesn't fetch pnpm@11.0.0-rc.5
from the real npmjs registry twice. The tool-dir assertion in that test
now doubles as a fork-bomb regression check for the v11-target path.
This commit is contained in:
Zoltan Kochan
2026-04-23 13:27:49 +02:00
committed by GitHub
parent a288b79ece
commit 08bf69c811
5 changed files with 92 additions and 3 deletions

View File

@@ -0,0 +1,13 @@
---
"@pnpm/exec.pnpm-cli-runner": minor
"@pnpm/tools.plugin-commands-self-updater": patch
"pnpm": patch
---
Fix an infinite fork-bomb that could happen when pnpm was installed with one version (e.g. `npm install -g pnpm@A`) and run inside a project whose `package.json` selected a different pnpm version via the `packageManager` field (e.g. `pnpm@B`), while a `pnpm-workspace.yaml` also existed at the project root.
The child process spawned by `installPnpmToTools` (to install the wanted pnpm version) inherited the parent's environment but had its working directory set to a fresh stage dir. pnpm's workspace walk-up from that stage dir would find the ancestor `pnpm-workspace.yaml` at the project root, adopt the root `package.json`, re-trigger `switchCliVersion`, and call `installPnpmToTools` again — recursively. Because the target tool dir isn't symlinked in until the outer install completes, each recursive call saw `alreadyExisted === false` and started another nested install, fork-bombing the process tree at ~100% CPU.
The child's environment is now forced to `manage-package-manager-versions=false` (v10) and `pm-on-fail=ignore` (v11+), which disables the package-manager-version handling in whichever pnpm runs as the child.
Fixes [#11337](https://github.com/pnpm/pnpm/issues/11337).

View File

@@ -1,10 +1,14 @@
import path from 'path'
import { sync as execSync } from 'execa'
export function runPnpmCli (command: string[], { cwd }: { cwd: string }): void {
export function runPnpmCli (
command: string[],
{ cwd, env }: { cwd: string, env?: NodeJS.ProcessEnv }
): void {
const execOpts = {
cwd,
stdio: 'inherit' as const,
...(env ? { env } : {}),
}
const execFileName = path.basename(process.execPath).toLowerCase()
if (execFileName === 'pnpm' || execFileName === 'pnpm.exe') {

View File

@@ -43,7 +43,12 @@ export async function switchCliVersion (config: Config): Promise<void> {
env: {
...process.env,
[pnpmEnv.name]: pnpmEnv.value,
// Disable the target pnpm's own package-manager-version management so
// it doesn't try to switch again. We set both names because v10 reads
// npm_config_* while v11+ reads pnpm_config_*, and this spawn may
// target either major.
npm_config_manage_package_manager_versions: 'false',
pnpm_config_pm_on_fail: 'ignore',
},
})

View File

@@ -1,5 +1,6 @@
import path from 'path'
import fs from 'fs'
import { packageManager } from '@pnpm/cli-meta'
import { prepare } from '@pnpm/prepare'
import { getToolDirPath } from '@pnpm/tools.path'
import { sync as writeJsonFile } from 'write-json-file'
@@ -88,7 +89,7 @@ test('commands that v10 passes through to npm keep passing through when packageM
expect(stdout.toString()).toContain('Bump a package version')
})
test('`pnpm version` routes through switchCliVersion to v11 when packageManager selects pnpm v11+', () => {
test('`pnpm version` routes through switchCliVersion to v11 when packageManager selects pnpm v11+, even with a pnpm-workspace.yaml at the project root', () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const version = '11.0.0-rc.5'
@@ -99,6 +100,11 @@ test('`pnpm version` routes through switchCliVersion to v11 when packageManager
PNPM_HOME: pnpmHome,
npm_config_registry: 'https://registry.npmjs.org/',
}
// pnpm-workspace.yaml at the project root is what lets the install-child's
// workspace walk-up see this dir's package.json + packageManager field.
// Before the #11337 fix this would fork-bomb; the tool-dir assertion below
// doubles as a fork-bomb regression check in the v11-target path.
fs.writeFileSync('pnpm-workspace.yaml', '')
writeJsonFile('package.json', {
packageManager: `pnpm@${version}`,
})
@@ -139,6 +145,47 @@ test('npm passthrough still fires when packageManager selects pnpm v11+ but swit
expect(stdout.toString()).toContain('Bump a package version')
})
test('switching does not fork-bomb when a pnpm-workspace.yaml at the project root is visible to the install-child (#11337 regression)', () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// pnpm-workspace.yaml at the project root is what makes the install-child's
// workspace walk-up hit this dir's package.json, pulling in its
// packageManager field and re-triggering switchCliVersion inside the child.
// Without the env-var guard in installPnpmToTools, that would recurse
// indefinitely because the target tool dir is not symlinked in yet.
fs.writeFileSync('pnpm-workspace.yaml', '')
writeJsonFile('package.json', {
packageManager: 'pnpm@9.3.0',
})
const { stdout } = execPnpmSync(['help'], { env, timeout: 60_000 })
expect(stdout.toString()).toContain('Version 9.3.0')
}, 90_000)
test('no spurious re-entry when the packageManager version matches the current pnpm, even with a pnpm-workspace.yaml at the root', () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// Same-version scenario: switchCliVersion must short-circuit at the
// `pm.version === packageManager.version` check (switchCliVersion.ts). The
// ancestor pnpm-workspace.yaml must not cause any detour through
// installPnpmToTools. If it did, `pnpm -v` would either hang or print the
// wrong version.
fs.writeFileSync('pnpm-workspace.yaml', '')
writeJsonFile('package.json', {
packageManager: `pnpm@${packageManager.version}`,
})
const { stdout } = execPnpmSync(['-v'], { env, timeout: 30_000 })
expect(stdout.toString().trim()).toBe(packageManager.version)
// And the tool dir must not have been created — no install should have run.
const toolDir = getToolDirPath({ pnpmHomeDir: pnpmHome, tool: { name: 'pnpm', version: packageManager.version } })
expect(fs.existsSync(path.join(toolDir, 'bin/pnpm'))).toBe(false)
}, 60_000)
test('throws error if pnpm tools dir is corrupt', () => {
prepare()
const pnpmHome = path.resolve('pnpm')

View File

@@ -52,7 +52,27 @@ export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateC
// which breaks the junctions on Windows.
'--config.node-linker=hoisted',
'--config.bin=bin',
], { cwd: stage })
// This is an isolated install into `stage` and must not inherit the
// caller's workspace context. Without this, the child's workspace
// walk-up from `stage` can discover an ancestor pnpm-workspace.yaml
// and treat the caller's project as the workspace root — breaking the
// add (it's outside the workspace's packages list) and, before the env
// guards below existed, picking up the caller's packageManager field
// and re-entering switchCliVersion for a fork bomb. See pnpm/pnpm#11337.
'--ignore-workspace',
], {
cwd: stage,
// Defense in depth against re-entering switchCliVersion in the child,
// in case any future code path surfaces a wantedPackageManager without
// going through workspace discovery. Both env-var names are set so the
// guard works regardless of whether the child reads pnpm's v10
// (npm_config_*) or v11+ (pnpm_config_*) convention.
env: {
...process.env,
npm_config_manage_package_manager_versions: 'false',
pnpm_config_pm_on_fail: 'ignore',
},
})
if (currentPkgName === '@pnpm/exe') {
linkExePlatformBinary(stage)
}