Skip to content

Agent-mode scan packages/<member> finds nothing in a pnpm workspace (exit 0), while rollback packages/<member> selects the same packages #778

Description

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

Summary

In a pnpm workspace with the default isolated linker, scan --mode agent <PATH> can't scope to a workspace member. scan --mode agent packages/a (also packages/a/** and packages/a/node_modules/left-pad) reports No installed packages found under the given path: packages/a., scans 0 packages and exits 0, even though packages/a depends on left-pad@1.3.0 and a patch for it exists. An unscoped scan, or scan --mode agent node_modules/.pnpm, finds and patches it.

rollback packages/a uses the same pattern and does select left-pad. Its results list both copies: ./node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad and ./packages/a/node_modules/left-pad. So the two commands that CLI_CONTRACT says share glob semantics disagree on the most common monorepo scope.

The cause looks like this. Every pnpm workspace member's node_modules/<dep> is a symlink into the root node_modules/.pnpm. The npm crawler's seen name@version dedup records each package once, at the first path it meets, and the root .pnpm store is walked before the member trees. Scan's path filter then sees only the .pnpm copy. Rollback's find_all_packages_for_rollback returns every copy, including the member link.

Impact

  • The CLI_CONTRACT example scan packages/foo (and apps/**) never selects anything in a pnpm workspace. CI that patches per member with socket-patch scan --mode agent --json packages/<name> gets status: success, scannedPackages: 0, exit 0, and the member's vulnerable dependency stays unpatched with no warning.
  • Patch and undo aren't symmetric: rollback packages/a reverts packages that scan packages/a could never have applied.
  • A single-package pnpm project isn't affected: the root node_modules/left-pad link is recorded first, so scan node_modules/left-pad matches. Neither is node-linker=hoisted (members have no node_modules).

Repro (pnpm 12.8.1, Linux, main 045d7ec)

mkdir -p ws/packages/a ws/packages/b && cd ws
echo '{"name":"root","version":"1.0.0","private":true}' > package.json
printf "packages: ['packages/*']\n" > pnpm-workspace.yaml
echo '{"name":"a","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > packages/a/package.json
echo '{"name":"b","version":"1.0.0","dependencies":{"is-number":"7.0.0"}}' > packages/b/package.json
pnpm install
ls -l packages/a/node_modules/   # left-pad -> ../../../node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad

# patch API serving a patch for pkg:npm/left-pad@1.3.0 (local mock used here)
socket-patch scan --mode agent --dry-run --json packages/a          # scannedPackages 0, packagesWithPatches 0
socket-patch scan --mode agent --yes packages/a                     # "No installed packages found under the given path: packages/a."  exit 0
socket-patch scan --mode agent --dry-run --json node_modules/.pnpm  # scannedPackages 2, packagesWithPatches 2
socket-patch scan --mode agent --dry-run --json                     # scannedPackages 2, packagesWithPatches 2

# rollback with the same pattern (after an unscoped apply of a local manifest + blobs)
socket-patch apply --offline
socket-patch rollback --offline --dry-run --json packages/a
#   "manifest": {"removedEntries": ["pkg:npm/left-pad@1.3.0"]}
#   results[].path: ./node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad, ./packages/a/node_modules/left-pad

Expected vs actual

CLI_CONTRACT.md, "Path-scoped scans": in agent mode, "a package is in scope iff ANY of its crawled installed copies sits under a matching path", "a pattern matching any ancestor directory of the copy path also matches, so a bare scan packages/foo scopes the whole subtree". It also says the glob semantics are "shared with rollback's path targets".

  • Expected: scan --mode agent packages/a selects left-pad@1.3.0 (its installed copy for member a is packages/a/node_modules/left-pad), just as rollback packages/a does.
  • Actual: scan sees only the deduplicated .pnpm path. The scope is empty and the run exits 0 with nothing patched.

OS × version

OS pnpm scan packages/a (and /**, and the link path) rollback packages/a
Linux 8.15.9 (lock 6.0) 0 packages, exit 0 selects
Linux 9.15.9 0 packages, exit 0 selects
Linux 10.34.5 0 packages, exit 0 selects
Linux 12.8.1 0 packages, exit 0 (reproduced 2×) selects (both copies listed)
Linux 10.34.5 node-linker=hoisted n/a: there's no member node_modules n/a
Linux 12.8.1 single-package project, scan node_modules/left-pad pass (1 package) selects

macOS and Windows weren't tested, but the crawl order and dedup aren't OS-specific.

First bad version

This isn't a regression. Release 4.0.0 has no scan [PATHS]; path-scoped scans arrived with v5 on main.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/mod.rs:1750: the scope filter tests pkg.path of the crawled records, which hold one path per name@version.
  • crates/socket-patch-core/src/crawlers/npm_crawler.rs:1811: the seen name@version dedup. The root .pnpm store wins before the workspace member links are visited.
  • crates/socket-patch-cli/src/commands/rollback.rs:1374: rollback matches against find_all_packages_for_rollback, which returns every copy, including member links. That's why the two commands diverge.

Other symlinked layouts (yarn's pnpm linker, Bun's isolated linker, vlt, npm install-strategy=linked) probably behave the same way. Each sibling routine can confirm its own.

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