* fix: allow pnpm v10 to switch/self-update to pnpm v12
pnpm v12 (the Rust port) publishes the `pnpm` and `@pnpm/exe` packages
with placeholder bins that a preinstall script replaces with the host's
native binary from a `@pnpm/exe.<platform>-<arch>[-musl]` optional
dependency. Because pnpm installs its own engine with --ignore-scripts,
that relinking never ran, leaving a non-executable placeholder and a
broken switch/self-update.
installPnpmToTools now:
- converges on the native `pnpm` package for v12+ (equal content to
`@pnpm/exe`, ships every target incl. darwin-x64);
- relinks the native binary for any wrapper that ships one (`@pnpm/exe`
for all majors and, from v12, `pnpm`), recognizing both the legacy
`@pnpm/<os>-<arch>` and the new `@pnpm/exe.<platform>-<arch>[-musl]`
platform-package naming; and
verifyPnpmEngineIdentity now verifies the native platform package's npm
registry signature for v12's `pnpm` wrapper, not only `@pnpm/exe".
* fix(security): verify pnpm engine before relinking and sanitize wrapper bin names
Addresses a Windows-only pre-verification write primitive: linkExePlatformBinary
read bin *keys* from the not-yet-verified wrapper package.json and used them as
path.join destinations. A crafted bin map (e.g. '../../evil') could unlink/hardlink
outside the staged dir before verifyPnpmEngineIdentity ran.
- Run verifyPnpmEngineIdentity before linkExePlatformBinary so no file whose name
derives from the wrapper's own manifest is mutated until the wrapper is proven
to be a genuine, signed pnpm release.
- Add safeWrapperBinNames() as defense in depth: relink only bin names that
resolve to a direct child of the wrapper dir (no '..', separators, or absolute
paths).
* fix(package-bins): reject reserved manifest bin names
Manifest bin keys "", ".", "..", and scoped forms such as "@scope/.."
passed the bin-name guard because encodeURIComponent leaves them
unchanged. When joined to the global bin directory during global
remove/update/add operations, "." resolves to the bin directory itself
and ".." to its parent, which removeBin then recursively deletes.
Reject empty, ".", and ".." bin names after scope stripping.
Backport of pnpm/pnpm#12289 to v10.
* fix: block untrusted request destination env expansion
Makes environment expansion trust-aware for registry/auth config and
request destinations:
- Stops project and workspace .npmrc files from expanding ${...}
placeholders in registry/proxy request destinations, URL-scoped keys,
and registry credential values.
- Stops repository-controlled pnpm-workspace.yaml from expanding
${...} placeholders in the registry setting.
- Preserves env expansion for trusted user/global/CLI/env config so
existing token and registry setup flows continue to work.
Backport of pnpm/pnpm#12291 (CAND-PNPM-122 / GHSA-3qhv-2rgh-x77r) to v10.
* fix(security): verify npm registry signature before spawning a package-manager binary
The packageManager field (and pnpm self-update) makes pnpm download and
run a specific pnpm version. The staged install's bytes were trusted
based on lockfile integrity alone, which proves nothing when the inputs
are repository-controlled.
pnpm now verifies the npm registry signature of the engine it is about
to spawn, over the installed integrity, against npm's public signing
keys embedded in the pnpm CLI (exactly as corepack does):
- verifyPnpmEngineIdentity() checks pnpm/@pnpm/exe and the materialized
platform binaries of the staged install before it is linked into the
tools directory.
- Fails closed: any verification failure, including an unreachable
registry, refuses the version switch rather than running an unverified
binary. Runs only on a tools-directory cache miss (an actual
download).
- The embedded keys live in a generated file kept in sync with npm's
keys endpoint by scripts/update-npm-signing-keys.mjs; the release
workflow runs the check as a gate so a key rotation cannot silently
break verification.
Backport of pnpm/pnpm#12292 (CAND-PNPM-097) to v10.
* fix: harden package-manager bootstrap metadata
Resolve package-manager bootstrap traffic through trusted user/CLI
registries and trusted network config, defaulting to the public npm
registry instead of project/workspace registry settings:
- getConfig() now computes packageManagerRegistries and
packageManagerNetworkConfig from trusted config sources only (CLI
options, env config, user and global .npmrc) — never the repository's
project/workspace .npmrc or pnpm-workspace.yaml.
- switchCliVersion() applies that bootstrap config when installing and
verifying the wanted pnpm version, so repository .npmrc
proxy/TLS/registry values cannot steer package-manager bootstrap
traffic.
Backport of pnpm/pnpm#12296 to v10. The v11 env-lockfile validation
parts do not apply: v10 bootstraps the wanted version through a staged
child install instead of an env lockfile.
* fix(security): verify Node.js runtime SHASUMS OpenPGP signature
When a repository requests a Node.js runtime (useNodeVersion or an
execution env), pnpm downloads and then executes a Node binary. The
download mirror is repository-configurable via node-mirror:<channel> in
project .npmrc, and the integrity came from SHASUMS256.txt fetched from
that same mirror — a circular check a malicious mirror can satisfy with
a tampered binary and matching hashes.
pnpm now fetches SHASUMS256.txt.sig and verifies its detached OpenPGP
signature against the Node.js release team's public keys, embedded in
the pnpm CLI, before trusting the hashes:
- @pnpm/crypto.shasums-file: new fetchVerifiedNodeShasums /
fetchVerifiedNodeShasumsFile verify the signature via openpgp against
the embedded keys (generated src/nodeReleaseKeys.ts, mirrored from
the canonical nodejs/release-keys list).
- @pnpm/node.fetcher verifies the configurable-mirror SHASUMS for the
release channel; pre-release channels (rc, nightly, ...) are unsigned
by Node and remain unverified.
- scripts/update-node-release-keys.mjs keeps the keys current
(pnpm run check:node-release-keys / update:node-release-keys), and
the release workflow runs the check as a gate.
Backport of pnpm/pnpm#12295 to v10 (without the pacquet Rust port,
which does not exist on this branch).
* test(env): sign the SHASUMS fixture for Node.js download tests
The Node.js download tests exercise the release channel, whose
SHASUMS256.txt is now signature-verified. Sign the fixture with a
generated OpenPGP key and trust it through the new
trustedNodeReleaseKeys test seam (threaded from plugin-commands-env via
@pnpm/node.fetcher to fetchVerifiedNodeShasums), so the tests keep
exercising the verification path instead of bypassing it.
* fix(self-updater): redact registry credentials from engine identity errors
Registry URLs may legally embed basic-auth credentials
(https://user:pass@host/). verifyPnpmEngineIdentity() interpolated the
packument URL and registry URL into PnpmError messages, and the
unreachable-registry path surfaced fetch-layer error messages that embed
the request URL — all of which land in terminal output and CI logs.
Strip URL credentials from every error message and truncate the non-200
response body.
* fix: update vulnerable transitive dependencies
Override shell-quote to >=1.8.4 (GHSA-w7jw-789q-3m8p, critical, pulled
in via concurrently) so the audit workflow passes again. The advisory
was published after the last release/10 audit run; it is unrelated to
the security backports on this branch.
`pnpm self-update` defaults to the `latest` dist-tag, but `latest` on the
registry can lag the installed version when a new major has shipped
without being tagged. Refuse to downgrade in that case. Users can still
run `pnpm self-update latest` (explicit) to force the downgrade.
Closes#11418
When self-updating from a v10 @pnpm/exe install on Intel macOS
(darwin-x64) to a v11+ version, transparently install the JS-only
"pnpm" npm package instead of @pnpm/exe.
@pnpm/exe v11+ no longer ships a darwin-x64 binary because Node.js
SEA injection produces a binary that segfaults on Intel Macs (upstream
nodejs/node#62893; pnpm/pnpm#11423). Without this swap, self-update
would resolve @pnpm/exe@11.x, install a manifest with no platform
binary, and leave the user with a broken bin shim.
The new install requires Node.js on PATH; a globalWarn explains the
swap when it happens. All other host/version combinations are
unchanged. The targetPkgName is also threaded through getToolDirPath,
the `pnpm add` command, and the linkExePlatformBinary guard so the
new install lives under the correct tool directory and the
@pnpm/exe-specific platform-binary linking is skipped for the JS path.
* feat(self-update): point users at the migration guide across major bumps
When `pnpm self-update <version>` crosses a pnpm major (upward) from
the version being upgraded from, print a one-line pointer to the
versioned migration guide on pnpm.io.
The "from" version is the project's `packageManager` pin when present
(via the existing `managePackageManagerVersions` gate), falling back
to the running binary's version otherwise. No-op updates
(target === previous) are silent.
v11 points at https://pnpm.io/11.x/migration. Future majors register
an entry in the in-file `MAJOR_UPGRADE_HINTS` table.
This is the v10-line backport of the corresponding main-branch change
in #11354.
* fix: update lockfile
* 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.
When pnpm is installed as a standalone executable in environments without
a system Node.js (e.g. Docker containers), the `@pnpm/exe` preinstall
script (`node setup.js`) fails because `node` is not on PATH. This broke
version switching via the `packageManager` field in package.json since
v10.30.2, which changed `getCurrentPackageName()` to return `@pnpm/exe`
instead of platform-specific package names like `@pnpm/linux-x64`.
Install with `--ignore-scripts` and link the platform-specific binary
in-process instead. The setup logic is inlined because setup.js can't
be loaded at runtime: `require()` fails on ESM (pnpm v11+) and
`import()` is intercepted by pkg's virtual filesystem in standalone
executables.
Closes#10687
Revert the workaround that forced `pnpm self-update` to always install
the `pnpm` package for v11+. Since `@pnpm/exe` works again from
v11.0.0-alpha.7, `getCurrentPackageName()` now simply returns
`@pnpm/exe` when running as an executable, without platform-specific
package names.
* fix(self-update): respect custom registry when installing pnpm version
When managePackageManagerVersions is enabled and a custom registry is
configured in .npmrc, pnpm was attempting to auto-install the specified
version from registry.npmjs.org instead of respecting the user's custom
registry configuration.
This happens because installPnpmToTools runs in a temporary directory
outside the project, which doesn't automatically pick up the project's
.npmrc configuration. The fix explicitly passes the registry configuration
from opts.registries.default or opts.rawConfig.registry to the pnpm add
command via the --config.registry flag.
* refactor: self-update
* Update .changeset/cold-buckets-crash.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>