From ad4e1007d22c6a726df2dfc7456bab8cc2ec7e90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:25:36 +0000 Subject: [PATCH 1/3] Start fix for #661, #696 Assisted-by: Claude Code:claude-opus-5-5 From c2c96071ed5c3c27222c54663cd8ca26a24229c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:40:02 +0000 Subject: [PATCH 2/3] Find installs in pnpm's modulesDir pnpm 10.12+ with `modulesDir` set installs into `/.pnpm` and leaves no node_modules, so agent apply skipped every package as "not installed" and exited 0 unpatched, and hosted vex attested not_affected over the unpatched install. The crawler now treats the configured modulesDir (pnpm-workspace.yaml or .npmrc), or a project dir holding pnpm's .modules.yaml, as an install root. Hosted vex also stops excusing a missing npm package as "nothing installed" when pnpm keeps the installed virtual store outside the project (global virtual store, or a virtualStoreDir that climbs out), since transitive deps there are invisible to the crawler. Fixes #661, #696. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/vex.rs | 12 +- .../tests/e2e_vex_redirect.rs | 138 ++++++++++ .../tests/in_process_alternate_installers.rs | 56 ++++ .../src/crawlers/npm_crawler.rs | 244 ++++++++++++++++++ 4 files changed, 449 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 43eff8991..ead1cab2a 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -598,11 +598,21 @@ async fn generate_vex( ) .is_empty() }; + // + // Nor did it look when pnpm keeps the installed virtual store + // outside the project (its global virtual store, or a + // `virtualStoreDir` that climbs out): only direct deps are linked + // into the project, so an npm purl not found may be an installed, + // unpatched transitive dep (#696). + let npm_store_hidden = + socket_patch_core::crawlers::npm_crawler::pnpm_store_outside_project(&common.cwd); + let hidden = |purl: &str| npm_store_hidden && purl.starts_with("pkg:npm/"); let mut lockfile_attested = Vec::new(); outcome.failed.retain(|f| { let excused = f.reason == "package_not_found" && plan.lockfile_basis.contains(&f.purl) - && crawled(&f.purl); + && crawled(&f.purl) + && !hidden(&f.purl); if excused { lockfile_attested.push(f.purl.clone()); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 6871394c4..fac4d5e63 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -2294,6 +2294,144 @@ fn yarn_modules_folder_install_is_hash_verified_not_lockfile_attested() { ); } +/// A pnpm v9 lock whose only package, `left-pad@1.3.0`, is pinned to its +/// hosted patch artifact. +fn pnpm_lock_pinning_left_pad() -> String { + format!( + "lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n left-pad:\n \ + specifier: 1.3.0\n version: 1.3.0\n\npackages:\n left-pad@1.3.0:\n \ + resolution: {{integrity: {SRI}, tarball: {}}}\n\nsnapshots:\n left-pad@1.3.0: {{}}\n", + hosted_npm_url("left-pad", "1.3.0", UUID) + ) +} + +/// REGRESSION (#696): pnpm 10.12+ with `modulesDir: deps` installs into +/// `deps/.pnpm` and there is no `node_modules`. The crawler never looked +/// there, so the unpatched installed copy read as "nothing installed" and +/// the pinned hosted lock attested `not_affected`. Installed evidence +/// wins: the copy is hash-checked and omitted. With nothing installed the +/// lock basis still attests. +#[test] +fn pnpm_modules_dir_install_is_hash_verified_not_lockfile_attested() { + for (file, text) in [ + ("pnpm-workspace.yaml", "modulesDir: deps\n"), + (".npmrc", "modules-dir=deps\n"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:npm/left-pad@1.3.0"; + std::fs::write( + cwd.join("package.json"), + r#"{ "name": "app", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" } }"#, + ) + .unwrap(); + std::fs::write(cwd.join(file), text).unwrap(); + std::fs::write(cwd.join("pnpm-lock.yaml"), pnpm_lock_pinning_left_pad()).unwrap(); + let pkg = cwd.join("deps/.pnpm/left-pad@1.3.0/node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{ "name": "left-pad", "version": "1.3.0" }"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), b"unpatched upstream bytes\n").unwrap(); + let patched = b"hosted patched index\n"; + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + left_pad_view(&compute_git_sha256_from_bytes(patched)), + )]); + + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!( + code, + Some(1), + "{file}: the unpatched deps/.pnpm copy must not attest: {env}" + ); + assert_eq!(skipped_reason(&env, purl), "hash_mismatch", "{file}: {env}"); + + std::fs::write(pkg.join("index.js"), patched).unwrap(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!(code, Some(0), "{file}: a patched store copy attests: {env}"); + + std::fs::remove_dir_all(cwd.join("deps")).unwrap(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!( + code, + Some(0), + "{file}: nothing installed: the lock basis attests: {env}" + ); + } +} + +/// REGRESSION (#696, follow-up variants): with pnpm's global virtual +/// store, or a `virtualStoreDir` outside the project, a TRANSITIVE dep is +/// installed only in that outside store, which the crawler does not walk. +/// "Not found" then is not "not installed": the pinned lock must not +/// attest over an install the crawler could not inspect. Without an +/// install the lock basis still attests. +#[test] +fn pnpm_store_outside_project_is_not_lockfile_attested() { + let outside = tempfile::tempdir().unwrap(); + let store = outside.path().join("v11/links"); + let copy = store.join("@/left-pad/1.3.0/abc/node_modules/left-pad"); + std::fs::create_dir_all(©).unwrap(); + std::fs::write( + copy.join("package.json"), + r#"{ "name": "left-pad", "version": "1.3.0" }"#, + ) + .unwrap(); + std::fs::write(copy.join("index.js"), b"unpatched upstream bytes\n").unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:npm/left-pad@1.3.0"; + std::fs::write( + cwd.join("package.json"), + r#"{ "name": "app", "version": "1.0.0" }"#, + ) + .unwrap(); + std::fs::write(cwd.join("pnpm-lock.yaml"), pnpm_lock_pinning_left_pad()).unwrap(); + let nm = cwd.join("node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + let recorded = format!("{}", store.display()).replace('\\', "\\\\"); + std::fs::write( + nm.join(".modules.yaml"), + format!("{{\"layoutVersion\": 5, \"virtualStoreDir\": \"{recorded}\"}}"), + ) + .unwrap(); + let patched = b"hosted patched index\n"; + let (_rt, server) = serve_patch_views(vec![( + UUID.to_string(), + left_pad_view(&compute_git_sha256_from_bytes(patched)), + )]); + + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!( + code, + Some(1), + "an install the crawler cannot see must not attest: {env}" + ); + assert_eq!(skipped_reason(&env, purl), "package_not_found", "{env}"); + + // A store inside the project is walked, so its absence is real. + let inside = cwd.join(".vstore"); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::write( + nm.join(".modules.yaml"), + "{\"layoutVersion\": 5, \"virtualStoreDir\": \"../.vstore\"}", + ) + .unwrap(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!(code, Some(0), "an in-project store hides nothing: {env}"); + + std::fs::remove_dir_all(&nm).unwrap(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + assert_eq!( + code, + Some(0), + "nothing installed: the lock basis attests: {env}" + ); +} + /// REGRESSION (#518): Rush installs every package into /// `common/temp/node_modules/.pnpm` and the projects' `node_modules` only /// link their DIRECT deps. The crawler pruned `temp`, so an unpatched diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 3686abaa3..4a1442824 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -1342,6 +1342,62 @@ async fn rush_transitive_dep_in_common_temp_store_is_patched() { ); } +/// REGRESSION (#661): with pnpm 10.12+ `modulesDir: deps`, pnpm installs +/// into `deps/.pnpm` and there is no `node_modules`. Agent-mode apply +/// reported the package not installed ("resolved by the project +/// lockfile") and exited 0 with it unpatched; the store copy is now +/// patched. +#[tokio::test] +#[serial] +async fn pnpm_modules_dir_install_is_patched() { + for (file, text) in [ + ("pnpm-workspace.yaml", "modulesDir: deps\n"), + (".npmrc", "modules-dir=deps\n"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write( + root.join("package.json"), + r#"{ "name": "app", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" } }"#, + ) + .unwrap(); + std::fs::write(root.join(file), text).unwrap(); + std::fs::write( + root.join("pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n left-pad:\n \ + specifier: 1.3.0\n version: 1.3.0\n\npackages:\n left-pad@1.3.0:\n \ + resolution: {integrity: sha512-upstream==}\n\nsnapshots:\n left-pad@1.3.0: {}\n", + ) + .unwrap(); + let pkg = root.join("deps/.pnpm/left-pad@1.3.0/node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + r#"{ "name": "left-pad", "version": "1.3.0" }"#, + ) + .unwrap(); + let original = b"module.exports = leftPad;\n".to_vec(); + std::fs::write(pkg.join("index.js"), &original).unwrap(); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-PNPM-MODULES-DIR-MARKER\n"); + let after_hash = git_sha256(&patched); + let socket = root.join(".socket"); + write_manifest(&socket, "pkg:npm/left-pad@1.3.0", &before_hash, &after_hash); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(root)).await; + assert_eq!( + code, 0, + "{file}: apply must find the pnpm modulesDir store copy" + ); + assert_patched(&pkg.join("index.js"), &patched, &before_hash, &after_hash); + assert!(!root.join("node_modules").exists()); + } +} + /// REGRESSION (#493): with `.yarnrc` `--modules-folder deps`, yarn classic /// installs into `deps/` and there is no `node_modules`. Agent-mode apply /// reported the package `package_not_installed` (exit 0 from `scan`, diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index c72e4ca45..866a8f415 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -41,6 +41,11 @@ const SKIP_DIRS: &[&str] = &[ /// store and the projects' own `node_modules` hold only links to their /// direct deps. /// +/// - pnpm's `modulesDir` (see [`pnpm_modules_dirs`]): pnpm installs the +/// project there instead of `node_modules`, and from pnpm 10.12 its +/// virtual store follows (`/.pnpm`), so nothing of the +/// install is under a dir named `node_modules` (#661). +/// /// Only existing directories are returned. pub(super) fn configured_install_roots(start_path: &Path) -> Vec { let mut roots = Vec::new(); @@ -50,10 +55,113 @@ pub(super) fn configured_install_roots(start_path: &Path) -> Vec { if start_path.join("rush.json").is_file() { roots.push(start_path.join("common").join("temp").join("node_modules")); } + roots.extend(pnpm_modules_dirs(start_path)); roots.retain(|root| root.is_dir()); + let mut seen = HashSet::new(); + roots.retain(|root| seen.insert(root.clone())); roots } +/// The project's pnpm `modulesDir` install roots, other than +/// `node_modules` itself: +/// - the configured setting ([`pnpm_modules_dir_setting`]), resolved +/// against the project like pnpm does, and honored only strictly inside +/// it (the value comes from the scanned project and names a tree apply +/// WRITES into; see [`resolve_modules_folder`]); +/// - any direct child dir holding pnpm's `.modules.yaml` install record, +/// which pnpm writes into whatever modules dir it used. That finds an +/// install whose `modulesDir` came from pnpm's global config or the +/// environment, which the project's files do not show. +fn pnpm_modules_dirs(start_path: &Path) -> Vec { + let mut dirs = Vec::new(); + if let Some(dir) = + pnpm_modules_dir_setting(start_path).and_then(|raw| resolve_modules_folder(&[], &raw)) + { + dirs.push(start_path.join(dir)); + } + let Some((entries, _)) = read_dir_entries_sync(start_path) else { + return dirs; + }; + for entry in entries { + let name = entry.file_name(); + if name == OsStr::new("node_modules") || !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let dir = start_path.join(name); + if std::fs::symlink_metadata(dir.join(PNPM_MODULES_YAML)).is_ok_and(|m| m.is_file()) { + dirs.push(dir); + } + } + dirs +} + +/// The raw pnpm `modulesDir` setting that applies to the project at +/// `start_path`: `modulesDir:` in the nearest `pnpm-workspace.yaml` at or +/// above it (the workspace's settings file on pnpm 10+, which wins over +/// `.npmrc`), else `modules-dir` from the nearest `.npmrc` at or above it +/// that sets it (pnpm up to 10). Read with +/// [`crate::utils::fs::read_regular_to_string_sync`]: the files belong to +/// the (untrusted) project. +fn pnpm_modules_dir_setting(start_path: &Path) -> Option { + let read = |path: PathBuf| crate::utils::fs::read_regular_to_string_sync(&path).ok(); + let from_workspace = start_path + .ancestors() + .find_map(|dir| read(dir.join("pnpm-workspace.yaml"))) + .and_then(|yaml| { + crate::utils::serde::strip_bom(&yaml) + .lines() + .filter_map(crate::formats::pnpm::workspace::top_level_key) + .rfind(|(key, _)| key == "modulesDir") + .map(|(_, value)| unquote_yaml_scalar(value)) + }); + from_workspace + .or_else(|| { + start_path.ancestors().find_map(|dir| { + let npmrc = read(dir.join(".npmrc"))?; + crate::patch::redirect::npmrc::npmrc_top_level_value(&npmrc, "modules-dir") + }) + }) + .filter(|value| !value.is_empty()) +} + +/// A YAML flow scalar's value: quotes removed (`''` is a literal quote +/// inside single quotes), a plain scalar as is. +fn unquote_yaml_scalar(raw: &str) -> String { + if raw.starts_with('"') { + if let Ok(value) = serde_json::from_str::(raw) { + return value; + } + } else if let Some(inner) = raw.strip_prefix('\'').and_then(|r| r.strip_suffix('\'')) { + return inner.replace("''", "'"); + } + raw.to_string() +} + +/// Whether the installed pnpm tree of the project at `project` keeps its +/// virtual store where the crawler does not look: a `.modules.yaml` in +/// `node_modules` or a pnpm modules dir ([`pnpm_modules_dirs`]) records a +/// `virtualStoreDir` outside the project, as pnpm's global virtual store +/// (`enableGlobalVirtualStore`) and a `virtualStoreDir` that climbs out +/// do. Only direct deps are linked into the project then, so a package +/// the crawler does not find may still be installed (as a transitive dep) +/// and must not be read as absent (#696). `false` with no pnpm install. +pub fn pnpm_store_outside_project(project: &Path) -> bool { + let mut modules_dirs = vec![project.join("node_modules")]; + modules_dirs.extend(pnpm_modules_dirs(project)); + modules_dirs.iter().any(|nm| { + let Ok(text) = crate::utils::fs::read_regular_to_string_sync(&nm.join(PNPM_MODULES_YAML)) + else { + return false; + }; + let Some(recorded) = parse_modules_yaml_virtual_store_dir(&text) else { + return false; + }; + let importer = normalize_lexically(project); + let store = normalize_lexically(&nm.join(recorded)); + store_below_importer(&importer, &store).is_none() + }) +} + /// Append the `configured` roots to the walk's `walked` roots. A walked /// root inside a configured one is a package's nested `node_modules` the /// walk mistook for a workspace (the walk descends into a modules folder @@ -5151,6 +5259,142 @@ mod tests { assert!(roots.is_empty(), "{roots:?}"); } + /// REGRESSION (#661, #696): pnpm's `modulesDir` (`modulesDir:` in + /// `pnpm-workspace.yaml`, `modules-dir=` in `.npmrc` up to pnpm 10) + /// renames `node_modules`, and from pnpm 10.12 the virtual store moves + /// with it to `/.pnpm`. The walk only collects dirs named + /// `node_modules`, so agent mode read the install as absent and hosted + /// `vex` attested the lock over it. The configured dir is a crawl root, + /// as is a project child holding pnpm's `.modules.yaml` (a value from + /// pnpm's global config or the environment). + #[tokio::test] + async fn test_pnpm_modules_dir_is_a_crawl_root() { + let configs = [ + ("pnpm-workspace.yaml", "modulesDir: deps\n"), + ( + "pnpm-workspace.yaml", + "packages:\n - packages/*\n'modulesDir' : \"./deps\" # moved\n", + ), + (".npmrc", "modules-dir=deps\n"), + ("deps/.modules.yaml", "{\"virtualStoreDir\": \".pnpm\"}"), + ]; + for (file, text) in configs { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("package.json"), r#"{"name":"app"}"#).unwrap(); + let store_copy = root.join("deps/.pnpm/left-pad@1.3.0/node_modules/left-pad"); + write_pkg(&store_copy, "left-pad", "1.3.0"); + let crawler = NpmCrawler::new(); + let options = local_options(root); + let has_left_pad = |pkgs: &[CrawledPackage]| { + pkgs.iter() + .any(|p| p.purl == "pkg:npm/left-pad@1.3.0" && p.path == store_copy) + }; + // Control: unconfigured, deps/ is not an install root. + assert!(!has_left_pad(&crawler.crawl_all(&options).await), "{file}"); + + std::fs::write(root.join(file), text).unwrap(); + let roots = crawler.get_node_modules_paths(&options).await.unwrap(); + assert_eq!(roots, vec![root.join("deps")], "{file}: {text}"); + assert!(has_left_pad(&crawler.crawl_all(&options).await), "{file}"); + let found = crawler + .find_by_purls(&root.join("deps"), &["pkg:npm/left-pad@1.3.0".to_string()]) + .await + .unwrap(); + assert_eq!(found.len(), 1, "{file}: {found:?}"); + } + } + + /// The `modulesDir` setting is read from the nearest + /// `pnpm-workspace.yaml` (pnpm resolves it against each project, so a + /// workspace member installs into its own `deps/`), wins over `.npmrc`, + /// and fails closed when it leaves the project. + #[test] + fn test_pnpm_modules_dir_setting_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + let member = repo.join("packages/member"); + std::fs::create_dir_all(member.join("deps")).unwrap(); + std::fs::create_dir_all(member.join("lib")).unwrap(); + std::fs::create_dir_all(repo.join("deps")).unwrap(); + std::fs::write(repo.join("pnpm-workspace.yaml"), "modulesDir: deps\n").unwrap(); + assert_eq!(configured_install_roots(&member), vec![member.join("deps")]); + assert_eq!(configured_install_roots(repo), vec![repo.join("deps")]); + + std::fs::write(member.join(".npmrc"), "modules-dir=lib\n").unwrap(); + assert_eq!(configured_install_roots(&member), vec![member.join("deps")]); + std::fs::write(repo.join("pnpm-workspace.yaml"), "packages: [packages/*]\n").unwrap(); + assert_eq!(configured_install_roots(&member), vec![member.join("lib")]); + + for outside in ["../deps", "/tmp/deps", "."] { + std::fs::write(member.join(".npmrc"), format!("modules-dir={outside}\n")).unwrap(); + assert!(configured_install_roots(&member).is_empty(), "{outside}"); + } + } + + /// REGRESSION (#696): an installed pnpm tree whose virtual store pnpm + /// recorded OUTSIDE the project (the global virtual store, or a + /// `virtualStoreDir` that climbs out) holds copies the crawler never + /// sees, so "not found" there does not mean "not installed". A store + /// inside the project, or no install at all, is not such a blind spot. + #[test] + fn test_pnpm_store_outside_project() { + let outside = tempfile::tempdir().unwrap(); + let shared: PathBuf = outside.path().components().collect(); + let tmp = tempfile::tempdir().unwrap(); + let root: PathBuf = tmp.path().components().collect(); + assert!(!pnpm_store_outside_project(&root), "nothing installed"); + + let nm = root.join("node_modules"); + std::fs::create_dir_all(&nm).unwrap(); + assert!(!pnpm_store_outside_project(&root), "not a pnpm install"); + let record = |dir: &Path, store: &str| { + let store = store.replace('\\', "\\\\"); + std::fs::write( + dir.join(".modules.yaml"), + format!("{{\"virtualStoreDir\": \"{store}\"}}"), + ) + .unwrap(); + }; + record(&nm, ".pnpm"); + assert!(!pnpm_store_outside_project(&root), "default store"); + std::fs::create_dir_all(root.join(".vstore")).unwrap(); + record(&nm, "../.vstore"); + assert!( + !pnpm_store_outside_project(&root), + "store inside the project" + ); + record(&nm, &format!("{}", shared.join("v11/links").display())); + assert!(pnpm_store_outside_project(&root), "global virtual store"); + record(&nm, "../../outside-vs"); + assert!( + pnpm_store_outside_project(&root), + "virtualStoreDir climbs out" + ); + std::fs::write( + nm.join(".modules.yaml"), + "layoutVersion: 5\nvirtualStoreDir: ../../outside-vs\n", + ) + .unwrap(); + assert!(pnpm_store_outside_project(&root), "YAML .modules.yaml"); + + // The same record under a configured `modulesDir`. + std::fs::remove_dir_all(&nm).unwrap(); + std::fs::write(root.join("pnpm-workspace.yaml"), "modulesDir: deps\n").unwrap(); + let deps = root.join("deps"); + std::fs::create_dir_all(&deps).unwrap(); + record(&deps, ".pnpm"); + assert!( + !pnpm_store_outside_project(&root), + "modulesDir default store" + ); + record(&deps, "../../outside-vs"); + assert!( + pnpm_store_outside_project(&root), + "modulesDir, store outside" + ); + } + /// REVIEW (#520): `--install.modules-folder` wins over /// `--modules-folder` whatever their line order, and when they come /// from different `.yarnrc` files (yarn merges each key through the From f548ad0dc35c8a8cb55d03c71b79b311d17ded4b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:52:35 +0000 Subject: [PATCH 3/3] Test real pnpm modulesDir; document it Adds a real-pnpm leg that installs with `modulesDir: deps` and checks agent apply patches the `deps/.pnpm` store copy, and records the new behavior in CLI_CONTRACT.md and the CHANGELOG. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 7 +++ crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/in_process_alternate_installers.rs | 63 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3572a298c..619c2256f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -206,6 +206,13 @@ limits, and required install commands. `virtualStoreDir`, instead of reporting them `package_not_installed` (#359, #362). A store outside the project, such as pnpm's global virtual store, is shared with other projects and is still not patched in place. +- Agent mode and `vex` find packages installed under pnpm's `modulesDir` + (`modulesDir:` in `pnpm-workspace.yaml`, or `modules-dir` in `.npmrc`). + From pnpm 10.12 the virtual store moves there (`/.pnpm`), so + `apply` exited 0 with the package unpatched as "not installed", and + hosted `vex` attested `not_affected` over the unpatched install. Hosted + `vex` also no longer attests a pinned npm package the crawler cannot see + because pnpm keeps the installed store outside the project (#661, #696). - npm locks keep their own layout when edited. `scan --mode hosted`, `scan --mode vendored`, `rollback` and `vendor --revert` re-serialized `package-lock.json` / `npm-shrinkwrap.json` with LF line diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..5cb1e49a5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -386,7 +386,7 @@ Recognition rules that hold for every ecosystem: | Wiring | Evidence (verify mode) | Marker | |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | -| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | +| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. | `(redirected)` | | Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`) must hash to the patched bytes, as `apply` patches every copy. One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 4a1442824..6d5553413 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -1342,6 +1342,69 @@ async fn rush_transitive_dep_in_common_temp_store_is_patched() { ); } +/// REGRESSION (#661), real pnpm: with `modulesDir: deps` (pnpm 10+ +/// reads `pnpm-workspace.yaml`, older pnpm `.npmrc` `modules-dir`), pnpm +/// 10.12+ puts the virtual store in `deps/.pnpm` and no package under +/// `node_modules`. Agent-mode apply must patch the store copy. Older pnpm +/// keeps the store in `node_modules/.pnpm`, which is not this layout, so +/// the leg skips there. +#[tokio::test] +#[serial] +async fn pnpm_modules_dir_install_then_apply_patches_file() { + if !has("pnpm") { + println!("SKIP: pnpm not on PATH"); + return; + } + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{ "name": "pnpm-md-test", "version": "0.0.0", "dependencies": { "ms": "2.1.3" } }"#, + ) + .unwrap(); + std::fs::write(tmp.path().join("pnpm-workspace.yaml"), "modulesDir: deps\n").unwrap(); + std::fs::write(tmp.path().join(".npmrc"), "modules-dir=deps\n").unwrap(); + + let status = pm_command("pnpm", &["npm_config_"]) + .args(["install", "--silent", "--no-frozen-lockfile"]) + .current_dir(tmp.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .expect("pnpm install"); + if !status.status.success() { + println!( + "SKIP: pnpm install failed: {}", + String::from_utf8_lossy(&status.stderr) + ); + return; + } + let ms_index = tmp + .path() + .join("deps/.pnpm/ms@2.1.3/node_modules/ms/index.js"); + if !ms_index.exists() { + println!("SKIP: this pnpm keeps its virtual store outside modulesDir (< 10.12)"); + return; + } + assert!(!tmp.path().join("node_modules/.pnpm").exists()); + + let original = std::fs::read(&ms_index).expect("read ms/index.js"); + let before_hash = git_sha256(&original); + let mut patched = original.clone(); + patched.extend_from_slice(b"\n// SOCKET-PATCH-PNPM-MODULES-DIR-REAL-MARKER\n"); + let after_hash = git_sha256(&patched); + + let socket = tmp.path().join(".socket"); + write_manifest(&socket, "pkg:npm/ms@2.1.3", &before_hash, &after_hash); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + std::fs::write(blobs.join(&after_hash), &patched).unwrap(); + + let code = apply_run(default_apply(tmp.path())).await; + assert_eq!(code, 0, "apply must patch the pnpm modulesDir install"); + assert_patched(&ms_index, &patched, &before_hash, &after_hash); +} + /// REGRESSION (#661): with pnpm 10.12+ `modulesDir: deps`, pnpm installs /// into `deps/.pnpm` and there is no `node_modules`. Agent-mode apply /// reported the package not installed ("resolved by the project