Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
09d4ed5
Start pnpm open-issue sweep
mikolalysenko Oct 7, 2026
3e32a82
Fix Rush pnpm >=11 hosted trust guidance (#713)
mikolalysenko Oct 7, 2026
1bb3cbb
Fix pnpm restore reading the default registry, not the .npmrc mirror …
mikolalysenko Oct 7, 2026
656bb3b
Fix agent-mode scan PATH scope missing workspace member copies (#778)
mikolalysenko Oct 7, 2026
3866a2c
Fix pnpm projects outside the workspace globs refused as members (#1006)
mikolalysenko Oct 7, 2026
ed668b4
Fix vendored pnpm refusing a pnpm-workspace.yaml exact-pin override (…
mikolalysenko Oct 7, 2026
47460ea
Fix duplicate apply/rollback events for pnpm workspace member links (…
mikolalysenko Oct 7, 2026
afbaaef
Fix pnpm restore following a tarball setting pnpm ignored (#902)
mikolalysenko Oct 7, 2026
ada052a
Keep pnpm workspace globs off dot directories (#1006)
mikolalysenko Oct 7, 2026
f26a30f
Fix Rush subspace repo-state stale warning never firing (#714)
mikolalysenko Oct 7, 2026
b9faf35
Ignore pnpm 11's env lockfile document in tarball evidence (#902)
mikolalysenko Oct 7, 2026
75d89c0
Skip the root-only pnpm-workspace.yaml on pnpm 9.0-10.4 (#734)
mikolalysenko Oct 7, 2026
0922685
Fix scan/get vendored dry-run promising a refused pnpm takeover (#853)
mikolalysenko Oct 7, 2026
554a022
Fix hosted scan ignoring per-member pnpm locks (#492)
mikolalysenko Oct 7, 2026
9bcdf7d
Fix pnpm 11+ global installs missing copies in other installs (#435)
mikolalysenko Oct 7, 2026
5cde022
Read pnpm-workspace.yaml registries in the pnpm restore (#919)
mikolalysenko Oct 7, 2026
80f78a8
Cover pnpm scope-registry, workspace-mirror and fallback restores (#919)
mikolalysenko Oct 7, 2026
f1b6bb2
Re-issue the Rush trust remedy on a re-run over redirected locks (#713)
mikolalysenko Oct 7, 2026
a913015
Reuse apply/rollback spawn helpers in pnpm multicopy tests (#633, #435)
mikolalysenko Oct 7, 2026
ac9f167
Format the pnpm registry lookup and tests; clippy is_none_or
mikolalysenko Oct 7, 2026
588466a
Fix unwinding a vendored pnpm parent clobbering its vendored child's …
mikolalysenko Oct 7, 2026
c85f86e
Fix pnpm gitBranchLockfile pins landing in a stale lock (#556)
mikolalysenko Oct 7, 2026
20849cb
Merge branch 'pnpm-fix/b1-workspace' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
0ed4ea0
Merge branch 'pnpm-fix/c-rollback' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
b185846
Merge branch 'pnpm-fix/d-vendored' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
d910a8a
Read pnpm's shared-lock setting like pnpm does (#492 review)
mikolalysenko Oct 7, 2026
9dbfb4f
Fix vendored pnpm editing the wrong document of a two-document lock (…
mikolalysenko Oct 7, 2026
f1a35da
Refuse member pnpm branch locks under per-member locks (#556 review)
mikolalysenko Oct 7, 2026
17a3d3c
Take over a quoted or commented pnpm-workspace.yaml exact pin (#854)
mikolalysenko Oct 7, 2026
7643644
Merge branch 'pnpm-fix/d-vendored' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
caad159
Merge branch 'pnpm-fix/b2-rush' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
dcae255
Merge origin/main into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
5b76673
Merge branch 'pnpm-fix/b3-lockselect' into agent/fix-pnpm-open-issues
mikolalysenko Oct 7, 2026
241c547
Address review findings on the pnpm open-issues branch
mikolalysenko Oct 7, 2026
828c1bc
Port pnpm per-member locks to the in-memory hosted engine (#492)
mikolalysenko Oct 7, 2026
b1c47aa
Confirm pnpm workspace members before demoting their locks (#492)
mikolalysenko Oct 7, 2026
a1e2dd4
Pin the pnpm bench fixture to pnpm 11 (#734)
mikolalysenko Oct 7, 2026
1447b32
Warn when the pnpm tarball restore guesses; name the pin in the scaff…
mikolalysenko Oct 7, 2026
3a11458
Fix the pnpm tarball-guess warning's remedy, scope and Rush handling …
mikolalysenko Oct 7, 2026
e7ae36d
Merge remote-tracking branch 'origin/main' into agent/fix-pnpm-open-i…
mikolalysenko Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/socket-patch-bench/src/fixtures/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,11 @@ pub fn build_pnpm(t: &mut Tree, size: Size) -> std::io::Result<Fixture> {
&format!("project/node_modules/{}", p.name),
)?;
}
// pnpm >= 11, so the scan still writes the `trustLockfile` scaffold (a
// pnpm 9.0-10.4 install record skips it, #734).
t.write(
"project/node_modules/.modules.yaml",
"layoutVersion: 5\nnodeLinker: isolated\npackageManager: pnpm@9.15.0\n",
"layoutVersion: 5\nnodeLinker: isolated\npackageManager: pnpm@11.0.0\n",
)?;
t.write("project/node_modules/.pnpm/lock.yaml", pnpm_lock(&g))?;
t.mkdir("home")?;
Expand Down
42 changes: 26 additions & 16 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

29 changes: 8 additions & 21 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ use crate::commands::lock_cli::acquire_or_emit;
use crate::commands::vex::{
generate_vex_from_manifest_path, generate_vex_without_manifest, ManifestlessVex, VexEmbedArgs,
};
use crate::ecosystem_dispatch::{find_all_packages_for_purls, partition_purls, JvmScope};
use crate::ecosystem_dispatch::{
distinct_install_dirs, distinct_npm_copies, find_all_packages_for_purls, partition_purls,
JvmScope,
};
use crate::json_envelope::{
AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile,
RunWarning, Status, VexSummary,
Expand Down Expand Up @@ -671,25 +674,6 @@ pub(crate) fn variant_matches_installed(first_file_status: Option<&VerifyStatus>
}
}

/// `paths` in order with every path that resolves to an already-listed
/// directory dropped: two discovered site-packages paths can name ONE
/// directory (a `lib64 -> lib` symlink, a symlinked venv), and patching it
/// twice would report the second pass `already_patched`. A path that can't
/// be canonicalized is kept as-is.
async fn distinct_install_dirs(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let key = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| path.clone());
if seen.insert(key) {
out.push(path.clone());
}
}
out
}

/// The file whose verify status decides whether a release variant
/// describes the installed distribution (fed to
/// [`variant_matches_installed`]).
Expand Down Expand Up @@ -1984,12 +1968,15 @@ async fn apply_patches_inner(
// physical copy per PURL. Patching only one would leave a live,
// vulnerable copy while reporting success (the multi-copy silent
// partial). The apply loop below iterates every copy.
let all_packages = find_all_packages_for_purls(
let mut all_packages = find_all_packages_for_purls(
&partitioned,
&crawler_options,
args.common.silent || args.common.json,
)
.await;
// One visit per physical copy: a pnpm workspace member's link into
// the root store is the same copy the root walk found (#633).
distinct_npm_copies(&mut all_packages).await;

if all_packages.is_empty() {
// Vendored purls are already accounted for (synthesized Skipped/
Expand Down
51 changes: 40 additions & 11 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1770,16 +1770,8 @@ async fn lock_text_refusals_for(
.filter(|url| !url.trim().is_empty())
.cloned()
.collect();
let pins = socket_patch_core::patch::redirect::upstream::HostedPin::all(
&socket_patch_core::vex::discover_patched_refs_with(
cwd,
&socket_patch_core::vex::DiscoverOptions {
patch_server_origins: origins.clone(),
},
)
.await,
);
let claimed: Vec<String> = pins.iter().map(|pin| canonical_purl(&pin.purl)).collect();
let pins = hosted_pins(cwd, origins.clone()).await;
let claimed = claimed_purls(&pins);
let fetchable: Vec<&PatchSearchResult> = selected
.iter()
.filter(|sr| bun_refusal.filter(|r| r.applies_to(&sr.purl)).is_none())
Expand Down Expand Up @@ -1825,6 +1817,37 @@ async fn lock_text_refusals_for(
refusals
}

/// The canonical purls the project's lockfiles pin hosted (a patch-server
/// tarball, Socket's own or one of `origins`): the purls whose vendored
/// run is a hosted → vendored takeover.
/// The fetch-phase gate ([`lock_text_refusals_for`]) and the scan preview
/// (`scan/vendor_flow.rs`) both read the takeovers through here, so the dry
/// run and the wet run agree on which purls they are.
pub(crate) async fn hosted_claimed_purls(cwd: &Path, origins: Vec<String>) -> Vec<String> {
claimed_purls(&hosted_pins(cwd, origins).await)
}

/// The project's hosted pins (see [`hosted_claimed_purls`]).
async fn hosted_pins(
cwd: &Path,
origins: Vec<String>,
) -> Vec<socket_patch_core::patch::redirect::upstream::HostedPin> {
socket_patch_core::patch::redirect::upstream::HostedPin::all(
&socket_patch_core::vex::discover_patched_refs_with(
cwd,
&socket_patch_core::vex::DiscoverOptions {
patch_server_origins: origins,
},
)
.await,
)
}

/// The canonical purls of `pins`.
fn claimed_purls(pins: &[socket_patch_core::patch::redirect::upstream::HostedPin]) -> Vec<String> {
pins.iter().map(|pin| canonical_purl(&pin.purl)).collect()
}

/// The record a detached ledger entry already carries for `purl` at
/// exactly `uuid` — the ledger store's idempotency skip (no view fetch).
/// Always `None` for the manifest store.
Expand Down Expand Up @@ -3789,7 +3812,13 @@ async fn run_get_vendored(
selected.iter().map(|p| p.purl.as_str()),
)
.await;
let preview = super::scan::preview_vendor_json(&args.common.cwd, selected, &takeover).await;
let preview = super::scan::preview_vendor_json(
&args.common.cwd,
selected,
&super::rollback::patch_server_origins(&args.common),
&takeover,
)
.await;
if args.common.json {
let mut result = serde_json::json!({
"status": "success",
Expand Down
8 changes: 6 additions & 2 deletions crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs};
use crate::commands::apply::is_local_go;
use crate::commands::lock_cli::acquire_or_emit;
use crate::commands::vendored_backend::{RevertedEntry, VendorRevertStep, VendoredBackend};
use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls, JvmScope};
use crate::ecosystem_dispatch::{
distinct_npm_copies, find_all_packages_for_rollback, partition_purls, JvmScope,
};
use crate::json_envelope::Command as EnvelopeCommand;
use crate::looks_like_uuid;
use crate::ui::{plural, StatusLine};
Expand Down Expand Up @@ -2160,12 +2162,14 @@ pub(crate) async fn rollback_patches_inner(
// one would leave the other copy still patched (silently divergent from
// the manifest's rolled-back state). The rollback loop below restores
// every copy.
let all_packages_multi = find_all_packages_for_rollback(
let mut all_packages_multi = find_all_packages_for_rollback(
&partitioned,
&crawler_options,
common.silent || common.json,
)
.await;
// One restore per physical copy, as apply patches them (#633).
distinct_npm_copies(&mut all_packages_multi).await;

// One representative path per PURL for the "is it installed" checks and
// the abort envelope's path display. The before-blob gate and the
Expand Down
24 changes: 23 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,23 @@ async fn vendored_takeover(
} else {
None
};
// pnpm twin: under `gitBranchLockfile` with a branch lock, the hosted
// rewrite leaves every pnpm lock alone (#556), so a vendored pnpm entry
// stays vendored rather than be reverted into neither mode.
let pnpm_entry = |entry: &socket_patch_core::vendor::VendorEntry| {
entry.ecosystem == "npm" && matches!(entry.flavor.as_deref(), Some("pnpm" | "pnpm-legacy"))
};
let pnpm_takeover_refusal = if takeover
.iter()
.any(|(_, entry)| entry.as_ref().is_some_and(pnpm_entry))
{
let view = socket_patch_core::vendor::lock_inventory::ProjectView::Disk(&common.cwd);
socket_patch_core::utils::pnpm_workspace::git_branch_locks(&view)
.await
.map(|branch| socket_patch_core::hosted::engine::git_branch_lock_refusal(&branch))
} else {
None
};
// Gradle twin: the hosted Gradle planner refuses builds and grants the
// vendored backend accepts (a custom `lockFile`, a settings-classpath
// GA, a same-GAV or incomplete grant, ...). Each refusal must be known
Expand Down Expand Up @@ -1881,7 +1898,7 @@ async fn vendored_takeover(
std::collections::HashMap::new()
};
// The takeover refusal (if any) for one candidate: bun gates every
// npm purl, berry and vlt only their own vendored entries, Gradle each
// npm purl, berry, vlt and pnpm only their own vendored entries, Gradle each
// of its own purls, a pypi purl on a platform-tagged grant (#701), and a
// requirements.txt entry on the hosted rewriter's reach (it pins only
// the root file, #699). Berry also runs
Expand Down Expand Up @@ -1929,6 +1946,11 @@ async fn vendored_takeover(
.clone()
.filter(|_| entry.is_some_and(vlt_entry))
})
.or_else(|| {
pnpm_takeover_refusal
.clone()
.filter(|_| entry.is_some_and(pnpm_entry))
})
};
// SYMLINK PRE-CHECK for the takeover reverts — the same rule as the
// SYMLINK GUARD below, applied to each ledger entry's recorded wiring
Expand Down
51 changes: 46 additions & 5 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ use std::path::{Path, PathBuf};

use crate::args::{apply_env_toggles, GlobalArgs};
use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
use crate::ecosystem_dispatch::{crawl_ecosystems, crawl_ecosystems_with_npm};
use crate::ecosystem_dispatch::{
crawl_ecosystems, crawl_ecosystems_with_npm, find_all_packages_for_rollback_reusing,
partition_purls,
};
use crate::ui::{self, plural, print_json, StatusLine};

use super::get::{download_and_apply_patches_with, DownloadParams, DownloadRun};
Expand Down Expand Up @@ -1779,7 +1782,11 @@ async fn run_scan(
// Crawl packages. Vendored mode keeps the npm half for its engine to
// reuse; hosted mode keeps it only for an embedded `--vex` (skipped
// under `--dry-run`). No other run pays for copying the snapshot.
let keep_npm = vendor || (hosted && args.vex.vex.is_some() && !args.common.dry_run);
// A path-scoped (agent or mode-less) run keeps it to resolve every
// installed copy for the scope filter below.
let keep_npm = vendor
|| (hosted && args.vex.vex.is_some() && !args.common.dry_run)
|| !path_scope.is_empty();
let (mut all_crawled, mut eco_counts, skipped_bundle_config_path, npm_crawl) = if keep_npm {
crawl_ecosystems_with_npm(&crawler_options, crawl_scope).await
} else {
Expand Down Expand Up @@ -1925,7 +1932,10 @@ async fn run_scan(

// PATH scoping, strictly AFTER the `scanned_purls` capture. A purl is
// in scope when ANY genuinely-crawled copy of it sits under a matching
// path.
// path. The crawl keeps one record per purl (for npm, the first copy
// the walk meets: a pnpm workspace's root `.pnpm` store entry, not the
// member's link to it), so a purl whose record misses is resolved to
// every installed copy the way `rollback`'s path targets are.
let filtered_crawled: Vec<_> = if path_scope.is_empty() {
filtered_crawled
} else {
Expand All @@ -1952,12 +1962,35 @@ async fn run_scan(
));
}
let scope = path_scope.bind(&args.common.cwd);
let in_scope: HashSet<String> = filtered_crawled
let mut in_scope: HashSet<String> = filtered_crawled
.iter()
.filter(|pkg| !supplement_purls.contains(&pkg.purl))
.filter(|pkg| scope.matches(&pkg.path))
.map(|pkg| pkg.purl.clone())
.collect();
let mut unresolved: Vec<String> = filtered_crawled
.iter()
.filter(|pkg| !supplement_purls.contains(&pkg.purl) && !in_scope.contains(&pkg.purl))
.map(|pkg| pkg.purl.clone())
.collect();
unresolved.sort();
unresolved.dedup();
if !unresolved.is_empty() {
let partitioned = partition_purls(&unresolved, args.common.ecosystems.as_deref());
let copies = find_all_packages_for_rollback_reusing(
&partitioned,
&crawler_options,
true,
npm_crawl.as_ref(),
)
.await;
in_scope.extend(
copies
.into_iter()
.filter(|(_, paths)| paths.iter().any(|p| scope.matches(p)))
.map(|(purl, _)| purl),
);
}
filtered_crawled
.into_iter()
.filter(|pkg| in_scope.contains(&pkg.purl))
Expand Down Expand Up @@ -3069,7 +3102,15 @@ async fn run_scan(
selected.iter().map(|p| p.purl.as_str()),
)
.await;
Some(preview_vendor_json(&args.common.cwd, &selected, &takeover).await)
Some(
preview_vendor_json(
&args.common.cwd,
&selected,
&crate::commands::rollback::patch_server_origins(&args.common),
&takeover,
)
.await,
)
} else {
None
};
Expand Down
Loading
Loading