From 70facdb670c2ab1710b52b24ba1b8774f5e4c392 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 12:43:15 -0400 Subject: [PATCH 1/4] WIP: Fix stranded berry resolutions after yarn remove (#1203) Co-Authored-By: Claude Opus 5.5 (1M context) From b81ea119b1b2d0bd33665f928e94b68470681153 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 13:25:48 -0400 Subject: [PATCH 2/4] Retire berry resolutions left by yarn remove After `yarn remove` or `yarn up` of a hosted-pinned yarn berry package, the root package.json keeps the Socket `resolutions` selector. That selector read as contested hosted wiring, so rollback, remove and list failed forever with hosted_wiring_contested, and re-running the hosted scan (the printed remedy) did not help. Discovery now records a hosted selector that no live berry lock entry resolves, and that matches no lock descriptor, as a stale selector. HostedInventory excuses it from contested wiring. rollback and remove retire it from package.json with a hosted_resolution_orphaned warning and leave yarn.lock alone. list succeeds and warns. Fixes #1203 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +- crates/socket-patch-cli/src/commands/list.rs | 20 ++ .../socket-patch-cli/src/commands/remove.rs | 4 +- .../socket-patch-cli/src/commands/rollback.rs | 4 +- .../tests/in_process_rollback_hosted.rs | 121 ++++++++++++ .../src/patch/redirect/upstream/mod.rs | 54 ++++- .../src/patch/redirect/upstream/npm.rs | 107 ++++++++++ .../src/vendor/lock_inventory/yarn.rs | 52 +++++ .../socket-patch-core/src/vex/discover/mod.rs | 27 +++ .../src/vex/discover/testing/golden.rs | 16 ++ .../src/vex/discover/yarn.rs | 66 +++++++ .../tests/hosted_inventory.rs | 185 ++++++++++++++++++ 12 files changed, 655 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..327915974 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -677,7 +677,7 @@ directory or tarball, a yarn classic registry or `file:` directory block of the like any other pin (#828, #935, #938, #939). The grant token of an attributed pin's own URL is never contested wiring where the same file also names that pin's patch uuid (a uv pin's paired `pyproject.toml` `[tool.uv.sources]` entry, a vlt pin in a `vlt-lock.json` whose pins -are withheld from the lock basis); any other unattributed uuid still is. `--vex` works as on the manifest-driven +are withheld from the lock basis); any other unattributed uuid still is. A yarn berry root `package.json` `resolutions` selector routing to a Socket-hosted tarball that no live `yarn.lock` entry resolves any more, and that matches no descriptor the lock is keyed by (`yarn remove` or `yarn up` of a hosted-pinned package leaves it behind, since yarn never edits `resolutions`), is not contested either: it is Socket's own leftover pin. `list` lists around it with a `hosted_resolution_orphaned` warning, and `rollback` (unscoped, or scoped to its purl) and `remove ` retire it from `package.json` with the same warning code, leaving `yarn.lock` untouched (#1203). `--vex` works as on the manifest-driven path. Without hosted pins the no-manifest no-op below is unchanged. **Prebuilt vendor artifacts (`--vendor-source`)**: `service` is the default and `auto` is a compatibility alias. `build` is rejected at argument parsing. There is no local construction or fallback. Package-reference POSTs on the configured API/proxy (`--vendor-url` overrides it) return the download URL and integrity; `--patch-server-url` can override the download origin. The CLI verifies transfer integrity and patched-member afterHashes before writing. Pending builds, missing artifacts, service failures, wrong layouts and integrity mismatches fail closed. Fresh acquisition requires the network; healthy committed artifacts remain reusable offline. @@ -979,7 +979,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem | Key | Shape | Meaning | |---|---|---| | `error` | `{code, message}` | Only on `status: "error"` (v5.0, MAJOR: was a string). Codes: `manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `rollback_failed`, `lock_held` / `lock_io`, and `path_glob_invalid` (a usage error, exit 2). Per-result `results[*].error` stays a string. | -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`, `upstream_gem_stale_cache`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`, `upstream_gem_stale_cache`, `hosted_resolution_orphaned`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -1281,6 +1281,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | | `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently. | | `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` was removed. | +| `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped); `list` only reports it and names those commands. Never flips the exit. | | `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry, pnpm or vlt entry is restored from the version document of the registry the project resolves it against (`.yarnrc.yml` `npmRegistryServer`, the pnpm lock's sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries`, vlt's node registry); that registry could not be read (e.g. it needs credentials), so the default registry's document was used and the restored tarball URL may not be the mirror's. | | `upstream_gem_stale_cache` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#1260): a restored gem's `-.gem` is still in Bundler's cache dir (`cache_path`, default `vendor/cache`, resolved as for the Gem stale-install guard) and its sha256 is not the upstream one (the restored `CHECKSUMS` entry, else the rubygems.org compact index), or it could not be checked (`--offline`, a registry error). Bundler installs from that dir first, so a `bundle cache` taken while the hosted pin was live makes every later install fail on the upstream checksum (exit 37) or, on bundler < 2.6 frozen installs, keep installing the patched bytes. The detail names the file. Remedy: delete it, then run `bundle cache` to cache the upstream gem in its place (or `bundle install` if the project does not commit its cache; with the cache dir committed, a frozen install reads only the cache). Read-only: the restore never deletes it. Not raised for an archive whose sha256 matches upstream. | | `upstream_pnpm_tarball_setting_guessed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#902): nothing showed which pnpm wrote a hosted `pnpm-lock.yaml` (no unpinned registry entry that shows the setting, no `node_modules/.modules.yaml` install record, no package.json `packageManager` pin; for a Rush lock, no rush.json `pnpmVersion`), so its entries were restored with or without `tarball:` by pnpm 10's reading of `lockfileIncludeTarballUrl` (pnpm-workspace.yaml, else `.npmrc` `lockfile-include-tarball-url`), and pnpm 9 (which reads only `.npmrc`) or pnpm >= 11 (which reads only pnpm-workspace.yaml) would have read it the other way. The detail names the lock, the setting followed, the pnpm that disagrees and the entries. Remedy: pin the pnpm (package.json `packageManager`, or reinstall so the install record names it; rush.json `pnpmVersion` for Rush), or give the two files the same value so every pnpm reads it alike; a rollback or remove can then be redone by restoring the lock from version control and re-running. Not raised when evidence decided, when both files read the same on every pnpm, or when the tarball is one pnpm records regardless. | diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 92fe3bba7..f5be3fded 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -406,6 +406,26 @@ pub async fn run(args: ListArgs) -> i32 { eprintln!("Warning: {}", crate::ui::sentence_case(detail)); } } + // A hosted `resolutions` selector no lock installs any more (#1203) is + // no patch either: say how to retire it. + for pin in &inventory.stale { + let detail = format!( + "{} keeps a hosted `resolutions` entry for {} (patch {}) that no lockfile \ + installs any more; `socket-patch rollback` or `socket-patch remove {}` removes it", + pin.files.join(", "), + pin.purl, + pin.uuid, + pin.uuid + ); + if args.common.json { + warnings.push(RunWarning { + code: "hosted_resolution_orphaned".to_string(), + detail, + }); + } else if !args.common.silent { + eprintln!("Warning: {}", crate::ui::sentence_case(&detail)); + } + } let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent); // `combined_entries` folds only real records in (a record-less legacy diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 6e3591fad..549cf7469 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -365,7 +365,9 @@ pub async fn run(args: RemoveArgs) -> i32 { } else { Default::default() }; - let hosted_pins: Vec = hosted_inventory.pins.clone(); + // Leftover `resolutions` selectors (#1203) unwind like pins: the + // restore retires them from the manifest. + let hosted_pins: Vec = hosted_inventory.unwindable(); if manifest_missing { let vendor_ledger_exists = project_state && tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 02bcdf968..21dd52fb2 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -941,7 +941,9 @@ pub async fn run(args: RollbackArgs) -> i32 { } else { Default::default() }; - let hosted_pins: Vec = hosted_inventory.pins.clone(); + // Leftover `resolutions` selectors (#1203) unwind like pins: the + // restore retires them from the manifest. + let hosted_pins: Vec = hosted_inventory.unwindable(); if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() { // Hosted wiring the lockfiles name but cannot attribute is still diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 96770f778..44001ef37 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1881,3 +1881,124 @@ async fn remove_and_rollback_by_superseded_record_uuid_unhost_the_release() { ); } } + +// ── #1203: a berry `resolutions` pin `yarn remove` left behind ────────────── +// `yarn remove left-pad` deletes the hosted lock entry but never edits +// `resolutions`, so the Socket selector routes nothing. It used to read as +// contested wiring: `list`, `rollback` and `remove` failed forever with +// `hosted_wiring_contested`, and the printed remedy (re-scan) changed +// nothing. It is Socket's own leftover pin: listed around and retired. + +/// A yarn berry project whose hosted left-pad pin was `yarn remove`d: the +/// lock holds only the workspace, `package.json` still the selector. +fn write_berry_selector_left_by_yarn_remove(root: &Path) -> String { + let pkg = format!( + "{{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \ + \"resolutions\": {{\n \"left-pad@npm:1.2.3\": \"{LP_HOSTED_URL}\"\n }}\n}}\n" + ); + std::fs::write(root.join("package.json"), &pkg).unwrap(); + std::fs::write( + root.join("yarn.lock"), + "# This file is generated by running \"yarn install\" inside your project.\n\ + # Manual changes might be lost - proceed with caution!\n\n\ + __metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"app@workspace:.\":\n version: 0.0.0-use.local\n resolution: \"app@workspace:.\"\n \ + languageName: unknown\n linkType: soft\n", + ) + .unwrap(); + std::fs::write(root.join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); + pkg +} + +fn run_cli_json(cwd: &Path, args: &[&str]) -> (i32, Value) { + let out = scrubbed_cli() + .args(args) + .args([ + "--json", + "--offline", + "--patch-server-url", + "http://patch.test", + "--cwd", + cwd.to_str().unwrap(), + ]) + .output() + .expect("run socket-patch"); + let envelope: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{args:?} --json stdout must be a JSON envelope: {e}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), envelope) +} + +/// The `package.json` once the leftover selector is retired. +const BERRY_PKG_RETIRED: &str = + "{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true\n}\n"; + +#[test] +#[serial] +fn berry_selector_left_by_yarn_remove_is_listed_around() { + let tmp = tempfile::tempdir().unwrap(); + let pkg = write_berry_selector_left_by_yarn_remove(tmp.path()); + let (code, envelope) = run_cli_json(tmp.path(), &["list"]); + assert_eq!(code, 0, "list must succeed: {envelope}"); + assert!( + warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()), + "{envelope}" + ); + assert!( + !warning_codes(&envelope).contains(&"hosted_wiring_contested".to_string()), + "{envelope}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + pkg, + "list writes nothing" + ); +} + +#[test] +#[serial] +fn rollback_retires_a_berry_selector_left_by_yarn_remove() { + let tmp = tempfile::tempdir().unwrap(); + write_berry_selector_left_by_yarn_remove(tmp.path()); + let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + let (code, envelope) = run_rollback_subprocess(tmp.path(), &[]); + assert_eq!(code, 0, "rollback must succeed: {envelope}"); + assert_eq!(envelope["status"], "success", "{envelope}"); + assert!( + warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()), + "{envelope}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + BERRY_PKG_RETIRED + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + lock, + "the lock is not touched" + ); + // Nothing hosted is left: list is clean. + let (code, envelope) = run_cli_json(tmp.path(), &["list"]); + assert_eq!(code, 0, "{envelope}"); + assert!(warning_codes(&envelope).is_empty(), "{envelope}"); +} + +#[test] +#[serial] +fn remove_retires_a_berry_selector_left_by_yarn_remove() { + for target in [LP_UUID, LP_PURL] { + let tmp = tempfile::tempdir().unwrap(); + write_berry_selector_left_by_yarn_remove(tmp.path()); + let (code, envelope) = run_cli_json(tmp.path(), &["remove", target, "--yes"]); + assert_eq!(code, 0, "remove {target} must succeed: {envelope}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + BERRY_PKG_RETIRED, + "remove {target}" + ); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 944435d03..e2023f3b9 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -179,6 +179,13 @@ pub struct ContestedWiring { pub struct HostedInventory { pub pins: Vec, pub contested: Vec, + /// Leftover hosted wiring no lock installs any more: a yarn berry + /// `resolutions` selector `yarn remove` / `yarn up` left behind + /// ([`crate::vex::discover::StaleSelector`], #1203), one pin per + /// package version and patch, wired in the manifest only. Never listed + /// as a patch; `rollback` and `remove` retire it with a + /// `hosted_resolution_orphaned` warning ([`HostedInventory::unwindable`]). + pub stale: Vec, } impl HostedInventory { @@ -243,6 +250,31 @@ impl HostedInventory { .filter(|r| r.mode == WiringMode::Hosted) .map(|r| (norm(&r.file), r.uuid.as_str())) .collect(); + // A leftover selector's URL (its patch uuid and grant token) is + // retired wiring, not contested wiring, in the manifest naming it. + let mut stale_excused: BTreeSet<(String, String)> = BTreeSet::new(); + let mut stale: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for selector in &discovery.stale_selectors { + if pinned.contains(selector.uuid.as_str()) { + continue; + } + let file = norm(&selector.file); + for segment in url_uuid_segments(&selector.url) { + stale_excused.insert((file.clone(), segment)); + } + stale + .entry((selector.purl.clone(), selector.uuid.clone())) + .or_default() + .insert(file); + } + let stale: Vec = stale + .into_iter() + .map(|((purl, uuid), files)| HostedPin { + purl, + uuid, + files: files.into_iter().collect(), + }) + .collect(); let is_pin_token = |uuid: &str, file: &str| { pin_tokens.get(uuid).is_some_and(|patches| { patches @@ -258,6 +290,7 @@ impl HostedInventory { && !pinned.contains(r.uuid.as_str()) && !is_pin_token(&r.uuid, &file) && (!pinned_files.contains(file.as_str()) || flagged_files.contains(&file)) + && !stale_excused.contains(&(file.clone(), r.uuid.clone())) { contested .entry(r.uuid.clone()) @@ -296,12 +329,23 @@ impl HostedInventory { } }) .collect(); - HostedInventory { pins, contested } + HostedInventory { + pins, + contested, + stale, + } } /// Whether the lockfiles wire any hosted patch at all. pub fn is_empty(&self) -> bool { - self.pins.is_empty() && self.contested.is_empty() + self.pins.is_empty() && self.contested.is_empty() && self.stale.is_empty() + } + + /// What `rollback` and `remove` unwind: the attributable pins plus the + /// leftover selectors ([`HostedInventory::stale`]), which the restore + /// retires from the manifest. + pub fn unwindable(&self) -> Vec { + self.pins.iter().chain(&self.stale).cloned().collect() } /// The refusal a management command raises while contested wiring @@ -688,6 +732,10 @@ impl Ctx<'_> { enum Format { NpmLock, YarnLock, + /// A yarn berry root `package.json` whose only hosted wiring is a + /// leftover `resolutions` selector ([`HostedInventory::stale`]); after + /// [`Format::YarnLock`], so it sees the berry restore's own edits. + PackageJson, PnpmLock, BunLock, /// Binary bun.lockb (restored only under [`RestoreOptions::bun_lockb`]). @@ -721,6 +769,7 @@ fn format_of(rel: &str) -> Format { match leaf { "package-lock.json" | "npm-shrinkwrap.json" => Format::NpmLock, "yarn.lock" => Format::YarnLock, + "package.json" => Format::PackageJson, "pnpm-lock.yaml" | "shrinkwrap.yaml" => Format::PnpmLock, "bun.lock" => Format::BunLock, "bun.lockb" => Format::BunLockb, @@ -857,6 +906,7 @@ async fn restore_pass(view: &mut View<'_>, active: &[&HostedPin], ctx: &Ctx<'_>) let result = match format { Format::NpmLock => npm::restore_npm_locks(view, &pins, &files, ctx).await, Format::YarnLock => npm::restore_yarn_locks(view, &pins, &files, ctx).await, + Format::PackageJson => npm::retire_stale_selectors(view, &pins, &files, ctx).await, Format::PnpmLock => npm::restore_pnpm_locks(view, &pins, &files, ctx).await, Format::BunLock => npm::restore_bun_locks(view, &pins, &files, ctx).await, Format::BunLockb if ctx.bun_lockb => bun_lockb::restore(view, &pins, &files, ctx).await, diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index c3d0d26e7..5b3145b09 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -796,6 +796,113 @@ async fn restore_berry( view.write(rel, doc.render(&moved)); } +/// Retire the leftover hosted `resolutions` selectors of a yarn berry root +/// `package.json` (#1203): `yarn remove` / `yarn up` deleted the lock entry +/// a hosted pin keyed by its tarball URL but left its selector, which now +/// routes nothing. Each in-scope pin's selectors are re-checked against +/// the sibling `yarn.lock` as the view holds it (the berry restore may +/// have run first) and dropped with a `hosted_resolution_orphaned` +/// warning; the lock itself is not touched. A pin with no such selector +/// left is not handled here, so it is refused like any unrestorable pin. +pub(crate) async fn retire_stale_selectors( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use crate::vendor::lock_inventory::yarn::{berry_entries, berry_selector_routes_nothing}; + + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let dir_prefix = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/"), + None => String::new(), + }; + let lock_rel = format!("{dir_prefix}yarn.lock"); + let lock = match view.read(&lock_rel).await { + Ok(Some(lock)) if crate::formats::yarn::is_berry_lock(strip_bom(&lock)) => { + berry_entries(strip_bom(&lock)) + } + _ => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{rel} routes a hosted patch but {lock_rel} is not a yarn berry lock"), + ); + continue; + } + }; + let Some(mut pkg) = serde_json::from_str::(strip_bom(&text)) + .ok() + .filter(Value::is_object) + else { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{rel} is not a JSON object"), + ); + continue; + }; + let Some(table) = pkg.get_mut("resolutions").and_then(Value::as_object_mut) else { + continue; + }; + let stale: Vec<(String, String, String)> = table + .iter() + .filter_map(|(selector, value)| { + let url = value.as_str()?; + let uuid = ctx.hosted_uuid(url)?; + (pins.contains_key(uuid.as_str()) + && berry_selector_routes_nothing(&lock, selector, url)) + .then(|| (selector.clone(), url.to_string(), uuid)) + }) + .collect(); + if stale.is_empty() { + continue; + } + for (selector, _, _) in &stale { + table.shift_remove(selector); + } + if table.is_empty() { + if let Some(obj) = pkg.as_object_mut() { + obj.shift_remove("resolutions"); + } + } + match crate::vendor::common::JsonLayout::of(&text) + .render(&pkg) + .map(String::from_utf8) + { + Ok(Ok(rendered)) => view.write(rel, rendered), + _ => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{rel} could not be re-serialized"), + ); + continue; + } + } + for (selector, url, uuid) in stale { + result.warnings.push(( + "hosted_resolution_orphaned", + format!( + "{rel}: removed the hosted `resolutions` entry `{selector}` ({url}); no \ + {lock_rel} entry installs it any more (the package was removed or moved \ + to another version), so it was leftover wiring" + ), + )); + result.handled.insert(uuid); + } + } + result +} + // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── /// What decides whether pnpm records a resolution's `tarball:` in the lock diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs index dcf8b704e..b135f6ad9 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -87,6 +87,58 @@ pub(crate) fn berry_entries(text: &str) -> BerryLock { BerryLock { cache_key, entries } } +/// Whether a root `package.json` `resolutions` selector routing to the +/// hosted `url` is leftover wiring the berry `lock` no longer installs +/// (#1203): no live entry resolves `url` (as its tarball locator or an +/// older pin's `__archiveUrl=` binding), and no live entry is keyed by a +/// descriptor the selector matches (`name@range` matches that exact +/// descriptor, a range-less selector any descriptor of the package). `yarn +/// remove` and `yarn up` leave such a selector behind: yarn never edits +/// `resolutions`, and a selector matching no descriptor is inert. A +/// selector that DOES match a descriptor the lock resolves elsewhere is not +/// leftover: the lock and the manifest disagree. +pub(crate) fn berry_selector_routes_nothing(lock: &BerryLock, selector: &str, url: &str) -> bool { + use crate::formats::yarn::patterns::resolution_selector_target; + let Some(target) = resolution_selector_target(selector) else { + return false; + }; + let selector = selector.trim(); + let last = selector + .rfind(target) + .map(|i| &selector[i..]) + .unwrap_or(target); + let range = split_pattern(last).map(|(_, range)| range); + for entry in lock.entries.iter().filter(|e| e.live) { + if let Some(locator) = entry.locator() { + if locator.reference == url + || locator.archive_url().is_some_and(|archive| { + archive == url + || crate::utils::purl::percent_decode_purl_component(archive) == url + }) + { + return false; + } + } + for pattern in &entry.patterns { + let Some((name, descriptor)) = split_pattern(pattern) else { + continue; + }; + if name != target { + continue; + } + match range { + None => return false, + Some(want) => { + if descriptor == want || descriptor.strip_prefix("npm:") == Some(want) { + return false; + } + } + } + } + } + true +} + /// A berry `checksum:` value as the pin yarn enforces: `/`, /// or — yarn 4.0.x's spelling under cacheKey `10c0` (4.1+ prefixes the /// cache key) — bare 128-hex promoted to `10c0/`. `None` for anything diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ab8bcbf08..8f4340bbe 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -611,6 +611,31 @@ pub struct Discovery { /// dropped as [`DIAG_REF_UNATTRIBUTABLE`] needs no record here: the GC /// keeps it through the diagnostic itself. Sorted, deduped. pub withheld: Vec, + /// Hosted `resolutions` selectors a yarn berry project's root + /// `package.json` still carries although its lock no longer installs + /// them ([`StaleSelector`], #1203). Not refs and not contested wiring: + /// the management commands retire them. Sorted, deduped. + pub stale_selectors: Vec, +} + +/// A Socket-hosted yarn berry `resolutions` selector whose lock entry is +/// gone: `yarn remove` (or `yarn up` to another version) deletes the +/// entry the hosted pin keyed by its tarball URL, but yarn never edits +/// `resolutions`, so the selector is left routing a descriptor nothing +/// depends on (see +/// [`crate::vendor::lock_inventory::yarn::berry_selector_routes_nothing`]). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct StaleSelector { + /// Root-relative manifest carrying the selector. + pub file: PathBuf, + /// The `resolutions` key. + pub selector: String, + /// The hosted tarball URL it routes to. + pub url: String, + /// The package version the URL's artifact is (canonical base purl). + pub purl: String, + /// The hosted patch uuid the URL names. + pub uuid: String, } /// One file discovery's guarded reads touched ([`Discovery::read`]). @@ -1101,6 +1126,8 @@ impl Discovery { self.read.dedup(); self.withheld.sort(); self.withheld.dedup(); + self.stale_selectors.sort(); + self.stale_selectors.dedup(); } } diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 5f6c65f66..4e62d89ca 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -132,6 +132,7 @@ fn render(out: &Discovery, root: &Path) -> Value { install_trees, read: _, withheld: _, + stale_selectors, // Already folded into `unattested` by the time a run returns. unwired_copies: _, } = out; @@ -263,6 +264,21 @@ fn render(out: &Discovery, root: &Path) -> Value { if !shadowed.is_empty() { rendered["shadowed"] = shadowed.iter().map(render_ref).collect::>().into(); } + if !stale_selectors.is_empty() { + rendered["stale_selectors"] = stale_selectors + .iter() + .map(|s| { + serde_json::json!({ + "file": path_str(&s.file), + "selector": s.selector, + "url": s.url, + "purl": s.purl, + "uuid": s.uuid, + }) + }) + .collect::>() + .into(); + } if !rewirable.is_empty() { rendered["rewirable"] = rewirable.iter().map(render_ref).collect::>().into(); } diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 5efa3a844..5d6a43989 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -364,6 +364,72 @@ async fn extract_berry(ctx: &DiscoverCtx<'_>, lock: BerryLock, out: &mut Discove record_copies(ctx, copies, out).await; confirm_berry_hosted_keyed(ctx, hosted_keyed, out).await; confirm_berry_vendored(ctx, vendored, out).await; + record_stale_hosted_selectors(ctx, &lock, out).await; +} + +/// Record each hosted `resolutions` selector of the root `package.json` +/// that the lock no longer installs ([`StaleSelector`], #1203): Socket's +/// own leftover pin after `yarn remove` / `yarn up`, which the management +/// commands retire instead of refusing around it as contested wiring. A +/// selector whose URL does not name its package version's artifact +/// (`-.tgz`) is left to the contested path. +/// +/// [`StaleSelector`]: super::StaleSelector +async fn record_stale_hosted_selectors( + ctx: &DiscoverCtx<'_>, + lock: &BerryLock, + out: &mut Discovery, +) { + use crate::vendor::lock_inventory::yarn::berry_selector_routes_nothing; + let Some(bytes) = ctx.read_bytes(PACKAGE_JSON, out).await else { + return; + }; + let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(&bytes); + let Ok(doc) = parse_json(PACKAGE_JSON, bytes) else { + return; + }; + let Some(res) = doc.get("resolutions").and_then(Value::as_object) else { + return; + }; + for (selector, value) in res { + let Some(url) = value.as_str() else { + continue; + }; + let Some(uuid) = ctx.hosted_uuid(url) else { + continue; + }; + let Some(name) = resolution_selector_target(selector) else { + continue; + }; + let Some(purl) = hosted_leaf_version(name, url) + .and_then(|version| crate::utils::purl::npm_purl(name, version)) + else { + continue; + }; + if !berry_selector_routes_nothing(lock, selector, url) { + continue; + } + out.stale_selectors.push(super::StaleSelector { + file: PACKAGE_JSON.into(), + selector: selector.clone(), + url: url.to_string(), + purl: crate::utils::purl_key::canonical_base_purl(&purl), + uuid, + }); + } +} + +/// The version a hosted npm artifact URL's leaf `-.tgz` +/// names for package `name`. +fn hosted_leaf_version<'u>(name: &str, url: &'u str) -> Option<&'u str> { + let path = url.split(['?', '#']).next()?; + let leaf = path.rsplit('/').next()?; + let bare = name.rsplit('/').next()?; + let version = leaf + .strip_prefix(bare)? + .strip_prefix('-')? + .strip_suffix(".tgz")?; + (!version.is_empty()).then_some(version) } /// Emit each berry hosted entry keyed by its tarball descriptor only when diff --git a/crates/socket-patch-core/tests/hosted_inventory.rs b/crates/socket-patch-core/tests/hosted_inventory.rs index 504e3ec40..8ab7e20bd 100644 --- a/crates/socket-patch-core/tests/hosted_inventory.rs +++ b/crates/socket-patch-core/tests/hosted_inventory.rs @@ -396,3 +396,188 @@ async fn lockless_nuget_pin_refusal_names_the_lockfile_remedy() { "a hosted re-scan cannot attribute a lockless pin: {refusal}" ); } + +// ── #1203: a berry `resolutions` pin `yarn remove` left behind ─────────── +// A hosted yarn berry pin lives in two places: the root package.json +// `resolutions` selector and the lock entry keyed by the hosted URL. `yarn +// remove` (or `yarn up` to an unpatched version) deletes the lock entry but +// never touches `resolutions`, so the selector routes nothing any more. It +// is Socket's own leftover wiring, not contested state: the management +// commands list around it and `rollback` / `remove` retire it. + +const MS_PATCH: &str = "44444444-4444-4444-8444-444444444444"; + +fn berry_hosted_url(name: &str, version: &str, patch: &str) -> String { + format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/{GRANT}/{patch}/{name}-{version}.tgz" + ) +} + +fn berry_lock(entries: &[(&str, &str, &str)]) -> String { + let mut text = String::from( + "# This file is generated by running \"yarn install\" inside your project.\n\ + # Manual changes might be lost - proceed with caution!\n\n\ + __metadata:\n version: 8\n cacheKey: 10c0\n", + ); + for (name, version, url) in entries { + text.push_str(&format!( + "\n\"{name}@{url}\":\n version: {version}\n resolution: \"{name}@{url}\"\n \ + checksum: 10c0/{}\n languageName: node\n linkType: hard\n", + "a".repeat(128) + )); + } + let deps: Vec = entries + .iter() + .map(|(name, version, _)| format!(" {name}: \"npm:{version}\"\n")) + .collect(); + text.push_str(&format!( + "\n\"app@workspace:.\":\n version: 0.0.0-use.local\n resolution: \"app@workspace:.\"\n{} \ + languageName: unknown\n linkType: soft\n", + if deps.is_empty() { + String::new() + } else { + format!(" dependencies:\n{}", deps.concat()) + } + )); + text +} + +/// A berry project hosted-pinned to left-pad and ms, after `yarn remove +/// left-pad` (`ms_pinned`: ms is still pinned, else it was removed too). +fn write_berry_removed(root: &std::path::Path, ms_pinned: bool) { + let lp = berry_hosted_url("left-pad", "1.3.0", PATCH); + let ms = berry_hosted_url("ms", "2.1.3", MS_PATCH); + let mut resolutions = serde_json::Map::new(); + resolutions.insert("left-pad@npm:1.3.0".into(), lp.clone().into()); + resolutions.insert("ms@npm:2.1.3".into(), ms.clone().into()); + let mut pkg = serde_json::json!({"name": "app", "version": "1.0.0", "private": true}); + if ms_pinned { + pkg["dependencies"] = serde_json::json!({"ms": "2.1.3"}); + } else { + resolutions.remove("ms@npm:2.1.3"); + } + pkg["resolutions"] = resolutions.into(); + std::fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&pkg).unwrap() + "\n", + ) + .unwrap(); + let entries: Vec<(&str, &str, &str)> = if ms_pinned { + vec![("ms", "2.1.3", ms.as_str())] + } else { + Vec::new() + }; + std::fs::write(root.join("yarn.lock"), berry_lock(&entries)).unwrap(); + std::fs::write(root.join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap(); +} + +#[tokio::test] +async fn berry_selector_left_by_yarn_remove_is_stale_not_contested() { + for ms_pinned in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + write_berry_removed(tmp.path(), ms_pinned); + let inv = inventory(tmp.path()).await; + assert!( + inv.contested.is_empty(), + "ms_pinned={ms_pinned}: a leftover selector is not contested: {inv:?}" + ); + assert!(inv.contested_refusal().is_none()); + let pinned: Vec<&str> = inv.pins.iter().map(|p| p.uuid.as_str()).collect(); + assert_eq!( + pinned, + if ms_pinned { vec![MS_PATCH] } else { vec![] }, + "{inv:?}" + ); + assert_eq!(inv.stale.len(), 1, "{inv:?}"); + assert_eq!(inv.stale[0].purl, "pkg:npm/left-pad@1.3.0"); + assert_eq!(inv.stale[0].uuid, PATCH); + assert_eq!(inv.stale[0].files, vec!["package.json".to_string()]); + assert!(!inv.is_empty(), "the leftover selector is hosted state"); + } +} + +#[tokio::test] +async fn berry_selector_is_only_retired_against_a_berry_lock() { + // No yarn.lock: nothing says whether yarn would route the selector + // (the project may never have been installed), so it is not retired. + let tmp = tempfile::tempdir().unwrap(); + write_berry_removed(tmp.path(), false); + std::fs::remove_file(tmp.path().join("yarn.lock")).unwrap(); + let inv = inventory(tmp.path()).await; + assert!(inv.stale.is_empty(), "{inv:?}"); + // A lock that still resolves the descriptor elsewhere disagrees with + // the manifest: contested, never retired. + write_berry_removed(tmp.path(), false); + std::fs::write( + tmp.path().join("yarn.lock"), + berry_lock(&[]).replace( + "\n\"app@workspace:.\"", + &format!( + "\n\"left-pad@npm:1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n \ + checksum: 10c0/{}\n languageName: node\n linkType: hard\n\n\"app@workspace:.\"", + "b".repeat(128) + ), + ), + ) + .unwrap(); + let inv = inventory(tmp.path()).await; + assert!(inv.stale.is_empty(), "{inv:?}"); + assert!(inv.contested_refusal().is_some(), "{inv:?}"); +} + +#[tokio::test] +async fn restore_retires_a_leftover_berry_selector() { + use socket_patch_core::patch::redirect::upstream::{ + restore_upstream, PinStatus, RestoreOptions, + }; + for ms_pinned in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + write_berry_removed(tmp.path(), ms_pinned); + let lock_before = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + let inv = inventory(tmp.path()).await; + // Offline: retiring a selector needs no registry lookup. + let opts = RestoreOptions { + offline: true, + ..Default::default() + }; + let outcome = restore_upstream(tmp.path(), &inv.stale, &opts).await; + assert_eq!(outcome.pins.len(), 1); + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + assert_eq!(outcome.reverted_files, vec!["package.json".to_string()]); + assert!( + outcome + .warnings + .iter() + .any(|(code, detail)| *code == "hosted_resolution_orphaned" + && detail.contains("left-pad@npm:1.3.0")), + "{:?}", + outcome.warnings + ); + let pkg: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join("package.json")).unwrap(), + ) + .unwrap(); + if ms_pinned { + let res = pkg["resolutions"].as_object().unwrap(); + assert!(!res.contains_key("left-pad@npm:1.3.0"), "{pkg}"); + assert!(res.contains_key("ms@npm:2.1.3"), "{pkg}"); + } else { + assert!(pkg.get("resolutions").is_none(), "{pkg}"); + } + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + lock_before, + "the lock is not touched" + ); + let after = inventory(tmp.path()).await; + assert!( + after.stale.is_empty() && after.contested.is_empty(), + "{after:?}" + ); + } +} From ea38bc007c10d4448a5d7fa1899dd0864725122e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:01:40 -0400 Subject: [PATCH 3/4] Keep patch identities out of retire warnings The list and restore warnings for a leftover berry selector named the patch uuid and the hosted URL, which carries the grant token. CodeQL flagged the uuid as cleartext logging. Name the file, the purl and the selector instead, as the contested refusal names files. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/list.rs | 9 +++++---- .../socket-patch-core/src/patch/redirect/upstream/npm.rs | 6 ++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index f5be3fded..bcc170eaf 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -409,13 +409,14 @@ pub async fn run(args: ListArgs) -> i32 { // A hosted `resolutions` selector no lock installs any more (#1203) is // no patch either: say how to retire it. for pin in &inventory.stale { + // Named by file and purl, as the contested refusal names files: a + // hosted URL carries its grant token, which output never prints. let detail = format!( - "{} keeps a hosted `resolutions` entry for {} (patch {}) that no lockfile \ - installs any more; `socket-patch rollback` or `socket-patch remove {}` removes it", + "{} keeps a hosted `resolutions` entry for {} that no lockfile installs any \ + more; `socket-patch rollback` or `socket-patch remove {}` removes it", pin.files.join(", "), pin.purl, - pin.uuid, - pin.uuid + pin.purl ); if args.common.json { warnings.push(RunWarning { diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index 5b3145b09..51330f00b 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -888,11 +888,13 @@ pub(crate) async fn retire_stale_selectors( continue; } } - for (selector, url, uuid) in stale { + // The detail names the selector, never its URL (which carries the + // grant token). + for (selector, _url, uuid) in stale { result.warnings.push(( "hosted_resolution_orphaned", format!( - "{rel}: removed the hosted `resolutions` entry `{selector}` ({url}); no \ + "{rel}: removed the hosted `resolutions` entry `{selector}`; no \ {lock_rel} entry installs it any more (the package was removed or moved \ to another version), so it was leftover wiring" ), From 992a598057b0379c1c0b6bb7a01a22601f030c5b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 9 Oct 2026 14:13:41 -0400 Subject: [PATCH 4/4] Point the list warning at a scoped remove The hosted_resolution_orphaned list warning suggested an unscoped rollback, which also restores every other hosted pin and vendored entry. Name `socket-patch remove `, which retires only the leftover selector. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/list.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 327915974..b0bd232dc 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1281,7 +1281,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | | `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently. | | `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` was removed. | -| `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped); `list` only reports it and names those commands. Never flips the exit. | +| `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped); `list` only reports it and names the scoped `remove ` that retires it alone. Never flips the exit. | | `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry, pnpm or vlt entry is restored from the version document of the registry the project resolves it against (`.yarnrc.yml` `npmRegistryServer`, the pnpm lock's sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries`, vlt's node registry); that registry could not be read (e.g. it needs credentials), so the default registry's document was used and the restored tarball URL may not be the mirror's. | | `upstream_gem_stale_cache` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#1260): a restored gem's `-.gem` is still in Bundler's cache dir (`cache_path`, default `vendor/cache`, resolved as for the Gem stale-install guard) and its sha256 is not the upstream one (the restored `CHECKSUMS` entry, else the rubygems.org compact index), or it could not be checked (`--offline`, a registry error). Bundler installs from that dir first, so a `bundle cache` taken while the hosted pin was live makes every later install fail on the upstream checksum (exit 37) or, on bundler < 2.6 frozen installs, keep installing the patched bytes. The detail names the file. Remedy: delete it, then run `bundle cache` to cache the upstream gem in its place (or `bundle install` if the project does not commit its cache; with the cache dir committed, a frozen install reads only the cache). Read-only: the restore never deletes it. Not raised for an archive whose sha256 matches upstream. | | `upstream_pnpm_tarball_setting_guessed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#902): nothing showed which pnpm wrote a hosted `pnpm-lock.yaml` (no unpinned registry entry that shows the setting, no `node_modules/.modules.yaml` install record, no package.json `packageManager` pin; for a Rush lock, no rush.json `pnpmVersion`), so its entries were restored with or without `tarball:` by pnpm 10's reading of `lockfileIncludeTarballUrl` (pnpm-workspace.yaml, else `.npmrc` `lockfile-include-tarball-url`), and pnpm 9 (which reads only `.npmrc`) or pnpm >= 11 (which reads only pnpm-workspace.yaml) would have read it the other way. The detail names the lock, the setting followed, the pnpm that disagrees and the entries. Remedy: pin the pnpm (package.json `packageManager`, or reinstall so the install record names it; rush.json `pnpmVersion` for Rush), or give the two files the same value so every pnpm reads it alike; a rollback or remove can then be redone by restoring the lock from version control and re-running. Not raised when evidence decided, when both files read the same on every pnpm, or when the tarball is one pnpm records regardless. | diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index bcc170eaf..fda140265 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -413,7 +413,7 @@ pub async fn run(args: ListArgs) -> i32 { // hosted URL carries its grant token, which output never prints. let detail = format!( "{} keeps a hosted `resolutions` entry for {} that no lockfile installs any \ - more; `socket-patch rollback` or `socket-patch remove {}` removes it", + more; `socket-patch remove {}` removes that entry alone", pin.files.join(", "), pin.purl, pin.purl