Skip to content

Fix npm crawler missing configured install roots (#493, #518) - #520

Open
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-npm-crawler-configured-roots
Open

Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-npm-crawler-configured-roots

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #493
Fixes #518

Summary

The npm crawler now also looks where the package manager is configured to install, not just in directories named node_modules:

For users this means agent-mode apply/scan now patches packages in these layouts instead of reporting them package_not_installed. It also means vex no longer issues a false not_affected: it used to read the uncrawled, unpatched installed copy as "nothing installed" and attest from the hosted lock pin. Now installed evidence wins (hash_mismatch), as on a default layout. The vendored vendored_tree_out_of_sync disclosure also fires for a modules-folder tree.

Root cause

NpmCrawler::find_local_node_modules_dirs (crates/socket-patch-core/src/crawlers/npm_crawler.rs) built the install roots only from a walk that collects directories literally named node_modules and prunes temp (SKIP_DIRS). It never consulted the package manager's own install-location config. Every consumer goes through get_node_modules_paths: apply, scan, vex verification, vex_consumed's alias walk, and the vendored out-of-sync check. So that one function caused all the reported symptoms.

Change

  • configured_install_roots(start_path): the configured roots listed above.
  • merge_configured_install_roots: appends those roots to the walk's roots. Walked roots inside a configured root are dropped (the walk descends into a non-node_modules modules folder and would otherwise report deps/<pkg>/node_modules as a workspace), and duplicates are skipped. Walk order is otherwise unchanged, so the seen dedup winners on existing layouts don't change.
  • A small .yarnrc (yarn lockfile syntax) reader for the key: bare or quoted key and value, optional :, # comments, BOM/CRLF, last setting wins. The file is read through the regular-file guard, and the value is normalized and gated like composer's config.vendor-dir. Absolute, drive-qualified or escaping values are ignored, so crawl and apply stay inside the project.
  • The test-only equivalence oracle (npm_crawler/oracle.rs) uses the same helper, so the randomized walk-equivalence tests still compare like with like.

Out of scope (not reported in either issue): Rush subspaces' per-subspace temp folders. Wrappers under npm/, pypi/, gem/ only dispatch to the binary and need no change.

Tests (red without the fix → green with it)

Issue Test Without fix
#493 npm_crawler::tests::test_yarnrc_modules_folder_is_a_crawl_root roots [deps/outer/node_modules], not [deps]
#493 npm_crawler::tests::test_parse_yarnrc_modules_folder (new parser)
#493 e2e_vex_redirect::yarn_modules_folder_install_is_hash_verified_not_lockfile_attested exit 0, verified / not_affected over the unpatched deps/ copy
#493 e2e_vex_vendor::vendored_modules_folder_tree_out_of_sync_warns no vendored_tree_out_of_sync warning
#493 in_process_alternate_installers::yarn_modules_folder_install_is_patched apply exit 1
#493 in_process_alternate_installers::yarn_classic_modules_folder_install_then_apply_patches_file (real yarn install 1.22.22) apply exit 1
#518 npm_crawler::tests::test_rush_common_temp_is_a_crawl_root roots lack common/temp/node_modules
#518 e2e_vex_redirect::rush_common_temp_install_is_hash_verified_not_lockfile_attested exit 0, verified / not_affected over the unpatched store copy
#518 in_process_alternate_installers::rush_transitive_dep_in_common_temp_store_is_patched apply exit 1
#518 e2e_redirect_rush_sim tier 1 (real pnpm@9 install --frozen-lockfile): new stale-store leg rush, unpatched store copy: exit Some(0)
both npm_crawler::tests::test_merge_configured_install_roots (new helper)
review test_normalize_modules_folder, test_escaping_modules_folder_is_not_a_crawl_root escaping ../outside / absolute dir crawled
review test_fifo_yarnrc_does_not_block_the_crawl (would block on open(2))

Commands run locally (Linux):

  • cargo clippy --workspace --all-features -- -D warnings (the CI command): clean.
  • cargo test -p socket-patch-core --lib: all crawler tests pass. Four tests elsewhere fail only because this container runs as root (read-only-directory tests can't fail a write as uid 0): copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_…, pypi_requirements::wire_failure_rolls_back_…. This PR doesn't touch them, and they pass in CI.
  • cargo test -p socket-patch-cli --all-features --test e2e_vex_redirect (27/27), --test e2e_vex_vendor (23/23), --test in_process_alternate_installers (21/21), --test e2e_redirect_rush_sim -- --ignored (3/3, real pnpm).
  • A full local cargo test --workspace --all-features build exceeds this container's disk allowance, so the full matrix ran in CI, where it is green.
  • cargo fmt --all -- --check isn't a CI gate and isn't clean on main. Only this PR's hunks were formatted; no unrelated reformatting is included.

Review and CI

  • Bugbot (549f8c0) found 2 real issues, both fixed in 113ae51 with regression tests (each thread replied to and resolved):
    • A .yarnrc FIFO could block the crawl. It's now read through the regular-file guard.
    • An unguarded --modules-folder path could escape the project. It's now normalized and gated like composer vendor-dir.
  • Bugbot found no new issues on 113ae51.
  • CI on 113ae51 is all green. native (ubuntu-latest, 0.0.0-16) (vlt) and native (ubuntu-latest, 1.3.10) (Bun) failed once during a production patch-API outage (Connection reset by peer, 02:28–02:35 UTC). Each passed on its single re-run.

🤖 Generated with Claude Code

https://claude.ai/code/session_012pqLNDF3U1KUabGfPsxoZ8


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
The npm crawler found a project's installed packages only in dirs
named node_modules, so it never looked where yarn classic installs
with `--modules-folder` or where Rush installs (common/temp). Agent
mode reported those packages as not installed and left them
unpatched, and hosted vex treated the unpatched copy as absent and
attested the patch from the lockfile pin alone.

The crawler now also reads the nearest .yarnrc --modules-folder and
adds common/temp/node_modules in a Rush repo (rush.json at the root).

Fixes #493, #518

Assisted-by: Claude Code:claude-opus-5-5
A real yarn classic install with --modules-folder is patched by agent
apply, and on a real pnpm-installed Rush repo vex now refuses to
attest when the installed copy under common/temp is unpatched.

Refs #493, #518

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 2, 2026 02:11
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Bare read of workspace .yarnrc
    • Replaced bare std::fs::read_to_string with read_regular_to_string_sync to prevent blocking on FIFO files at the .yarnrc path.
  • ✅ Fixed: Unguarded yarn modules-folder path
    • Added normalize_modules_folder function to normalize paths and applied is_safe_multi_segment check to prevent path traversal and escaping project root.

Create PR

Or push these changes by commenting:

@cursor push 515f68cd5e
Preview (515f68cd5e)
diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs
@@ -8,7 +8,7 @@
 use super::types::{CrawledPackage, CrawlerOptions};
 use super::walk_pool::{par_map, run_walk};
 use crate::patch::path_safety;
-use crate::utils::fs::{is_dir, is_dir_sync, read_dir_entries_sync};
+use crate::utils::fs::{is_dir, is_dir_sync, read_dir_entries_sync, read_regular_to_string_sync};
 use crate::utils::purl::{percent_decode_purl_component, strip_purl_qualifiers};
 use crate::vendor::vlt_lock_text::decode_vlt_dep_id;
 
@@ -46,7 +46,11 @@
 pub(super) fn configured_install_roots(start_path: &Path) -> Vec<PathBuf> {
     let mut roots = Vec::new();
     if let Some(folder) = yarnrc_modules_folder(start_path) {
-        roots.push(start_path.join(folder));
+        if let Some(normalized) = normalize_modules_folder(&folder) {
+            if path_safety::is_safe_multi_segment(&normalized) {
+                roots.push(start_path.join(normalized));
+            }
+        }
     }
     if start_path.join("rush.json").is_file() {
         roots.push(start_path.join("common").join("temp").join("node_modules"));
@@ -78,10 +82,12 @@
 }
 
 /// The `--modules-folder` value from the nearest `.yarnrc` at or above
-/// `start_path`, if any `.yarnrc` sets it.
+/// `start_path`, if any `.yarnrc` sets it. Read `.yarnrc` via
+/// `read_regular_to_string_sync` for the same reason `package.json` is:
+/// a FIFO planted at that workspace path would wedge a plain read forever.
 fn yarnrc_modules_folder(start_path: &Path) -> Option<String> {
     start_path.ancestors().find_map(|dir| {
-        let rc = std::fs::read_to_string(dir.join(".yarnrc")).ok()?;
+        let rc = read_regular_to_string_sync(&dir.join(".yarnrc")).ok()?;
         parse_yarnrc_modules_folder(&rc)
     })
 }
@@ -137,6 +143,29 @@
     (end > 0).then(|| (s[..end].to_string(), &s[end..]))
 }
 
+/// Reduce a `--modules-folder` value to plain `a/b` segments before the
+/// safety gate. Yarn accepts `./`-prefixed and `.`-interleaved values
+/// (`./deps`, `lib/./deps`) and either separator, so those shapes must
+/// resolve rather than be refused. `..` is resolved lexically the same way
+/// Composer's config.vendor-dir normalization does; a value that climbs
+/// above the project root (or reduces to it) fails closed as `None`.
+fn normalize_modules_folder(raw: &str) -> Option<String> {
+    if raw.starts_with(['/', '\\']) {
+        return None;
+    }
+    let mut segments: Vec<&str> = Vec::new();
+    for segment in raw.split(['/', '\\']) {
+        match segment {
+            "" | "." => {}
+            ".." => {
+                segments.pop()?;
+            }
+            other => segments.push(other),
+        }
+    }
+    (!segments.is_empty()).then(|| segments.join("/"))
+}
+
 // ---------------------------------------------------------------------------
 // Helper: read and parse package.json
 // ---------------------------------------------------------------------------
@@ -1237,7 +1266,11 @@
     /// Inside a store entry (`store_entry`) a link is a dependency edge into
     /// a sibling entry, whose own visit records that copy, so only a real
     /// directory there matches.
-    fn visit_resolver_dir(nm_path: PathBuf, store_entry: bool, pending: &[Target]) -> ResolverVisit {
+    fn visit_resolver_dir(
+        nm_path: PathBuf,
+        store_entry: bool,
+        pending: &[Target],
+    ) -> ResolverVisit {
         let listing = list_dir_sync(&nm_path);
         let probe_filter = ProbeFilter::new(&listing);
         let matched = pending
@@ -4329,6 +4362,30 @@
         }
     }
 
+    #[test]
+    fn test_normalize_modules_folder() {
+        let n = normalize_modules_folder;
+        // Yarn-legal `./` prefixes and `.` segments reduce to the
+        // plain path; either separator is accepted.
+        assert_eq!(n("./deps").as_deref(), Some("deps"));
+        assert_eq!(n("./lib/deps").as_deref(), Some("lib/deps"));
+        assert_eq!(n("lib/./deps").as_deref(), Some("lib/deps"));
+        assert_eq!(n("lib\\deps").as_deref(), Some("lib/deps"));
+        assert_eq!(n("lib/../deps").as_deref(), Some("deps"));
+        assert_eq!(n("deps").as_deref(), Some("deps"));
+        // Escaping the project, reducing to it, or absolute — fail closed.
+        assert_eq!(n(".."), None);
+        assert_eq!(n("../elsewhere"), None);
+        assert_eq!(n("lib/../.."), None);
+        assert_eq!(n("."), None);
+        assert_eq!(n("a/.."), None);
+        assert_eq!(n("/etc/deps"), None);
+        assert_eq!(n("\\\\share\\deps"), None);
+        // A drive-letter segment survives normalization; the
+        // `is_safe_multi_segment` gate downstream rejects the colon.
+        assert_eq!(n("C:\\deps").as_deref(), Some("C:/deps"));
+    }
+
     fn local_options(cwd: &Path) -> CrawlerOptions {
         CrawlerOptions {
             cwd: cwd.to_path_buf(),

You can send follow-ups to the cloud agent here.

Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs
Comment thread crates/socket-patch-core/src/crawlers/npm_crawler.rs
The .yarnrc --modules-folder value comes from the project being
scanned and names a tree apply writes patches into. An absolute or
escaping value (/etc, ../elsewhere) is now ignored, like composer's
vendor-dir, so the crawl and apply stay inside the project. The
.yarnrc is also read with the regular-file guard, so a FIFO planted
at that path can no longer hang scan, apply or vex.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 113ae51. Configure here.

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] native (ubuntu-latest, 0.0.0-16) (vlt patch compatibility) failed on 113ae51. It doesn't look like this PR's failure:

  • The failing case is 0.0.0-16-hosted-crlf-lock, and its failing checks changed between the harness's own retry attempts: rollbackByteIdentical/rollbackOriginalBytes first, then serveEncodingIdentity/cleanRefusalWhileEncoded, with "transport failure; retrying" in between. The other 32 of 34 cases passed, and agent-two-versions passed on its 3rd attempt.
  • The same job passed on this PR's previous head 549f8c0 (job) and on main 61cfb9b. The only change since 549f8c0 is in .yarnrc --modules-folder parsing and reading. A vlt fixture has no .yarnrc or rush.json, so its crawl roots are byte-for-byte the same as before.

No fix to port exists, since nothing in the diff reaches this path. I'll re-run the failed job once when the workflow run finishes. If it fails again I'll treat it as real and root-cause it.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] native (ubuntu-latest, 1.3.10) (Bun patch compatibility) also failed on 113ae51, with the same cause as the vlt job above. Between 02:28 and 02:35 UTC the harness got repeated Network error: error sending request for url (https://patches-api.socket.dev/patch/batch): … Connection reset by peer (and on /patch/view/…) from the production patch API. The cells that ran out of retries (direct hosted, custom-registry vendored) failed on those network errors, and 50 of 53 passed. Nothing in this diff touches the API client, and Bun fixtures have no .yarnrc or rush.json, so crawl roots are unchanged for them. I'll re-run the failed job once when the run finishes. The vlt job's re-run is already queued.


Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 2, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Ready for review at 113ae51.

  • CI: 383/383 non-skipped check runs green (6 skipped). The vlt 0.0.0-16 and Bun 1.3.10 native jobs each failed once during the 02:28–02:35 UTC patch-API Connection reset by peer outage. Both passed on their one re-run.
  • Bugbot: 2 findings on 549f8c0, both real and fixed in 113ae51 with regression tests (escaping/absolute --modules-folder values, FIFO .yarnrc blocking the crawl). It found no new issues on 113ae51. All threads are resolved.
  • Mergeable, 0 commits behind main.
  • For reviewers: .yarnrc --modules-folder parsing and the gate that only honors relative in-project subpaths (configured_install_roots / merge_configured_install_roots in npm_crawler.rs).

Generated by Claude Code

This branch has not been deployed

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

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

2 participants