Skip to content

Vendored pnpm 12 with packageManager set: the two-document pnpm-lock.yaml makes vendor refuse, and vendor --revert, rollback and the hosted takeover half-revert the project and break frozen installs #466

Description

[agent] Found by the scheduled pnpm bug-hunt routine (ledger #303).

Summary

When package.json has a packageManager: "pnpm@12.x" field, pnpm 12 writes pnpm-lock.yaml as two YAML documents. The first is an "env" document with its own importers: (configDependencies, packageManagerDependencies), packages: (the pnpm / @pnpm/exe.* entries) and snapshots:. The second is the project lock. pnpm 11.28.3 and 10.34.5 don't do this with the same package.json.

The vendored line-block planner finds sections with section_bounds, which returns the first column-0 packages: / importers: / snapshots: header. So on this lock it reads and edits the env document, except for overrides:, which exists only in the second document. As a result:

  1. scan --mode vendored / vendor refuses wrongly. It reports vendor_lock_entry_not_found ("pnpm-lock.yaml has no packages entry for left-pad@1.3.0 — make sure the package is installed and locked"), but the package is installed and locked. This fails closed (exit 1), but the refusal shouldn't fire.
  2. vendor --revert on a project vendored before the lock became two-doc: exit 0, status: success. It removes pnpm.overrides from package.json, deletes the scaffolded pnpm-workspace.yaml and removes the lock's overrides: block. It leaves all 5 file:.socket/vendor/... references in the importers, packages and snapshots of document 2. Every event is skipped / vendor_lock_entry_drifted ("packages entry ... no longer exists"), which is false. The next pnpm install --frozen-lockfile fails with ERR_PNPM_OUTDATED_LOCKFILE.
  3. rollback does the same edits. It then reports vendoredKept: [{reason: "lockfile wiring drifted; vendored state left untouched"}] and exits 1. The state was not left untouched: package.json, pnpm-workspace.yaml and the lock were all modified, and frozen installs break.
  4. Plain scan (v5 default hosted mode) does a vendored→hosted takeover. It warns redirect_takeover_reverted_vendored, deletes the vendor ledger (.socket/vendor/state.json), package.json overrides, the workspace file and the lock overrides: block, then warns redirect_pnpm_entry_vendored with redirected: 0. It exits 0 with status: success. The project is left with no ledger, no hosted pin and a lock that --frozen-lockfile rejects (ERR_PNPM_OUTDATED_LOCKFILE). Because the ledger is gone, a later rollback can only say "lockfiles still reference .socket/vendor/ artifacts but the vendor ledger is missing".

Hosted mode itself is fine on the two-doc lock. scan --mode hosted pins the entry in document 2, a fresh dead-registry frozen install lands the patched bytes, vex attests not_affected, and rollback restores the lock byte for byte.

Impact

packageManager is the standard way to pin pnpm (corepack), so any vendored pnpm 12 project with a pinned pnpm hits this. Adding packageManager to an already vendored project (or upgrading to pnpm 12 with it set) turns every unwind path into a silent, partial revert that breaks CI's frozen install. Two of those paths (vendor --revert and the default scan) exit 0 with success.

Repro

Needs pnpm 12.8.1, a socket-patch built from main, and a patch API serving a free patch for left-pad@1.3.0. A local mock of /v0/orgs/<org>/patches/{batch,package,view,by-package} and the hosted tarball was used, with SP="socket-patch … --api-url <mock> --org test-org --api-token fake".

set -u; W=$(mktemp -d); cd $W
echo '{"name":"app","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > package.json
$PNPM install --store-dir $W/.store >/dev/null
$SP scan --mode vendored --json --yes --cwd . >/dev/null 2>&1 && echo "1. vendored on single-doc lock: ok"
node -e 'const f="package.json",p=require("./"+f);p.packageManager="pnpm@12.8.1";require("fs").writeFileSync(f,JSON.stringify(p,null,2))'
$PNPM install --store-dir $W/.store >/dev/null
echo "2. lock documents: $(grep -c '^---$' pnpm-lock.yaml)"
cp -r $W $W.copy
$SP vendor --revert --json --yes --cwd . > revert.json 2>/dev/null; echo "3. vendor --revert exit=$? status=$(node -p 'require("./revert.json").status')"
echo "   package.json overrides left: $(grep -c overrides package.json); pnpm-workspace.yaml: $(test -f pnpm-workspace.yaml && echo kept || echo deleted); lock file:.socket refs left: $(grep -c 'file:.socket' pnpm-lock.yaml)"
rm -rf node_modules; $PNPM install --frozen-lockfile --store-dir $W/.store2 2>&1 | grep -o 'ERR_PNPM_[A-Z_]*' | head -1
cd $W.copy; $SP scan --json --cwd . > s.json 2>/dev/null; echo "4. plain scan (takeover) exit=$? status=$(node -p 'require("./s.json").status') redirected=$(node -p 'require("./s.json").redirect.redirected')"
rm -rf node_modules; $PNPM install --frozen-lockfile --store-dir $W/.store3 2>&1 | grep -o 'ERR_PNPM_[A-Z_]*' | head -1

Output (identical on two runs):

1. vendored on single-doc lock: ok
2. lock documents: 2
3. vendor --revert exit=0 status=success
   package.json overrides left: 0; pnpm-workspace.yaml: deleted; lock file:.socket refs left: 5
ERR_PNPM_OUTDATED_LOCKFILE
4. plain scan (takeover) exit=0 status=success redirected=0
ERR_PNPM_OUTDATED_LOCKFILE

For symptom 1, vendor straight onto the two-doc lock: create the project with packageManager already set, run pnpm install, then scan --mode vendored. It exits 1 with download.patches[0].errorCode = "vendor_lock_entry_not_found".

Expected vs actual

  • Expected: the vendored planners address the document that holds the project lock (the one with the root importer's dependencies and the settings: header). That would make vendor, revert, rollback and takeover behave exactly as they do on the single-doc lock. A single-doc control on the same pnpm 12.8.1 passes: vendor, then rollback, gives a byte-exact lock, and the takeover gives redirected: 1 with a fresh frozen install landing the patched bytes. Failing that, the planners should refuse up front (fail closed) rather than half-revert.
  • CLI_CONTRACT.md (rollback JSON, vendoredKept): "Drift-keeps — wiring drifted, vendored state (and the manifest entry) left untouched". Here the state is edited even though it's reported as kept, and vendor --revert reports success on a revert that left the lock wired to the artifact.
  • formats/pnpm/mod.rs:290 documents the assumption: "pnpm 9-12 emit lockfileVersion: '9.0' (single doc, first line)".

Matrix (Linux, Node 22, main 2463257)

pnpm packageManager set lock docs vendor vendor --revert / rollback default-scan takeover hosted
12.8.1 yes 2 fail (vendor_lock_entry_not_found) fail (half-revert, frozen install broken) fail (exit 0, ledger lost, frozen install broken) pass
12.8.1 no 1 pass pass pass pass
11.28.3 yes 1 pass not run not run not run
10.34.5 yes 1 not run (single doc)

macOS and Windows weren't probed; the logic is OS-independent line splicing. First bad release: not bisected. Release 4.0.0 can't run against the v5-shaped mock, and v5 (#277) introduced the default-hosted takeover.

Suspect code

  • crates/socket-patch-core/src/formats/pnpm/lines.rs:14 section_bounds takes the first name: header in the file and ignores --- document separators.
  • crates/socket-patch-core/src/vendor/pnpm_lock.rs:509 (preflight_package → lock_has_target_package_in, line 1285) and the revert path that emits vendor_lock_entry_drifted. Every section lookup runs against document 1, but the overrides: lookup succeeds in document 2.
  • crates/socket-patch-core/src/formats/pnpm/mod.rs:290: the single-document assumption.

Side note (not filed separately): pnpm 12 prints The "pnpm" field in package.json is no longer read by pnpm … "pnpm.overrides" on every install of a vendored project. The workspace-file override is what takes effect, so this is noise only.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions