Skip to content

Commit a0730bb

Browse files
Retire berry resolutions left by yarn remove (#1203) (#1321)
* WIP: Fix stranded berry resolutions after yarn remove (#1203) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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 <purl>`, which retires only the leftover selector. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e979b7b commit a0730bb

12 files changed

Lines changed: 658 additions & 6 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ directory or tarball, a yarn classic registry or `file:` directory block of the
731731
like any other pin (#828, #935, #938, #939). The grant token of an attributed pin's
732732
own URL is never contested wiring where the same file also names that pin's patch uuid (a uv
733733
pin's paired `pyproject.toml` `[tool.uv.sources]` entry, a vlt pin in a `vlt-lock.json` whose pins
734-
are withheld from the lock basis); any other unattributed uuid still is. `--vex` works as on the manifest-driven
734+
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 <uuid|purl>` retire it from `package.json` with the same warning code, leaving `yarn.lock` untouched (#1203). `--vex` works as on the manifest-driven
735735
path. Without hosted pins the no-manifest no-op below is unchanged.
736736

737737
**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.
@@ -1037,7 +1037,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
10371037
| Key | Shape | Meaning |
10381038
|---|---|---|
10391039
| `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. |
1040-
| `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`, `yarn_berry_node_gyp_unresolved`), `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) |
1040+
| `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`, `yarn_berry_node_gyp_unresolved`, `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) |
10411041
| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. |
10421042
| `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) |
10431043
| `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept |
@@ -1338,6 +1338,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
13381338
| `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. |
13391339
| `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. |
13401340
| `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` (the one under its `# socket-patch hosted` comment) was removed; a user-authored override is never removed and never reported. |
1341+
| `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 <purl>` that retires it alone. Never flips the exit. |
13411342
| `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 (yarn berry: `npmScopes` / `YARN_NPM_REGISTRY_SERVER` / `npmRegistryServer` from the `.yarnrc.yml` chain, as above, 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) or, for yarn berry, could not be determined (an rc value referencing an environment variable that is set, or an unset one with no default; a flow-style `npmScopes`), so the default registry's document was used and the restored tarball URL may not be the mirror's. |
13421343
| `upstream_gem_stale_cache` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#1260): a restored gem's `<name>-<version>.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. |
13431344
| `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. |

‎crates/socket-patch-cli/src/commands/list.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,27 @@ pub async fn run(args: ListArgs) -> i32 {
406406
eprintln!("Warning: {}", crate::ui::sentence_case(detail));
407407
}
408408
}
409+
// A hosted `resolutions` selector no lock installs any more (#1203) is
410+
// no patch either: say how to retire it.
411+
for pin in &inventory.stale {
412+
// Named by file and purl, as the contested refusal names files: a
413+
// hosted URL carries its grant token, which output never prints.
414+
let detail = format!(
415+
"{} keeps a hosted `resolutions` entry for {} that no lockfile installs any \
416+
more; `socket-patch remove {}` removes that entry alone",
417+
pin.files.join(", "),
418+
pin.purl,
419+
pin.purl
420+
);
421+
if args.common.json {
422+
warnings.push(RunWarning {
423+
code: "hosted_resolution_orphaned".to_string(),
424+
detail,
425+
});
426+
} else if !args.common.silent {
427+
eprintln!("Warning: {}", crate::ui::sentence_case(&detail));
428+
}
429+
}
409430
let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);
410431

411432
// `combined_entries` folds only real records in (a record-less legacy

‎crates/socket-patch-cli/src/commands/remove.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,9 @@ pub async fn run(args: RemoveArgs) -> i32 {
365365
} else {
366366
Default::default()
367367
};
368-
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
368+
// Leftover `resolutions` selectors (#1203) unwind like pins: the
369+
// restore retires them from the manifest.
370+
let hosted_pins: Vec<HostedPin> = hosted_inventory.unwindable();
369371
if manifest_missing {
370372
let vendor_ledger_exists = project_state
371373
&& tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))

‎crates/socket-patch-cli/src/commands/rollback.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,9 @@ pub async fn run(args: RollbackArgs) -> i32 {
941941
} else {
942942
Default::default()
943943
};
944-
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
944+
// Leftover `resolutions` selectors (#1203) unwind like pins: the
945+
// restore retires them from the manifest.
946+
let hosted_pins: Vec<HostedPin> = hosted_inventory.unwindable();
945947

946948
if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() {
947949
// Hosted wiring the lockfiles name but cannot attribute is still

‎crates/socket-patch-cli/tests/in_process_rollback_hosted.rs‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,3 +1881,124 @@ async fn remove_and_rollback_by_superseded_record_uuid_unhost_the_release() {
18811881
);
18821882
}
18831883
}
1884+
1885+
// ── #1203: a berry `resolutions` pin `yarn remove` left behind ──────────────
1886+
// `yarn remove left-pad` deletes the hosted lock entry but never edits
1887+
// `resolutions`, so the Socket selector routes nothing. It used to read as
1888+
// contested wiring: `list`, `rollback` and `remove` failed forever with
1889+
// `hosted_wiring_contested`, and the printed remedy (re-scan) changed
1890+
// nothing. It is Socket's own leftover pin: listed around and retired.
1891+
1892+
/// A yarn berry project whose hosted left-pad pin was `yarn remove`d: the
1893+
/// lock holds only the workspace, `package.json` still the selector.
1894+
fn write_berry_selector_left_by_yarn_remove(root: &Path) -> String {
1895+
let pkg = format!(
1896+
"{{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \
1897+
\"resolutions\": {{\n \"left-pad@npm:1.2.3\": \"{LP_HOSTED_URL}\"\n }}\n}}\n"
1898+
);
1899+
std::fs::write(root.join("package.json"), &pkg).unwrap();
1900+
std::fs::write(
1901+
root.join("yarn.lock"),
1902+
"# This file is generated by running \"yarn install\" inside your project.\n\
1903+
# Manual changes might be lost - proceed with caution!\n\n\
1904+
__metadata:\n version: 8\n cacheKey: 10c0\n\n\
1905+
\"app@workspace:.\":\n version: 0.0.0-use.local\n resolution: \"app@workspace:.\"\n \
1906+
languageName: unknown\n linkType: soft\n",
1907+
)
1908+
.unwrap();
1909+
std::fs::write(root.join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap();
1910+
pkg
1911+
}
1912+
1913+
fn run_cli_json(cwd: &Path, args: &[&str]) -> (i32, Value) {
1914+
let out = scrubbed_cli()
1915+
.args(args)
1916+
.args([
1917+
"--json",
1918+
"--offline",
1919+
"--patch-server-url",
1920+
"http://patch.test",
1921+
"--cwd",
1922+
cwd.to_str().unwrap(),
1923+
])
1924+
.output()
1925+
.expect("run socket-patch");
1926+
let envelope: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
1927+
panic!(
1928+
"{args:?} --json stdout must be a JSON envelope: {e}\nstdout:\n{}\nstderr:\n{}",
1929+
String::from_utf8_lossy(&out.stdout),
1930+
String::from_utf8_lossy(&out.stderr)
1931+
)
1932+
});
1933+
(out.status.code().unwrap_or(-1), envelope)
1934+
}
1935+
1936+
/// The `package.json` once the leftover selector is retired.
1937+
const BERRY_PKG_RETIRED: &str =
1938+
"{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true\n}\n";
1939+
1940+
#[test]
1941+
#[serial]
1942+
fn berry_selector_left_by_yarn_remove_is_listed_around() {
1943+
let tmp = tempfile::tempdir().unwrap();
1944+
let pkg = write_berry_selector_left_by_yarn_remove(tmp.path());
1945+
let (code, envelope) = run_cli_json(tmp.path(), &["list"]);
1946+
assert_eq!(code, 0, "list must succeed: {envelope}");
1947+
assert!(
1948+
warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()),
1949+
"{envelope}"
1950+
);
1951+
assert!(
1952+
!warning_codes(&envelope).contains(&"hosted_wiring_contested".to_string()),
1953+
"{envelope}"
1954+
);
1955+
assert_eq!(
1956+
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
1957+
pkg,
1958+
"list writes nothing"
1959+
);
1960+
}
1961+
1962+
#[test]
1963+
#[serial]
1964+
fn rollback_retires_a_berry_selector_left_by_yarn_remove() {
1965+
let tmp = tempfile::tempdir().unwrap();
1966+
write_berry_selector_left_by_yarn_remove(tmp.path());
1967+
let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap();
1968+
let (code, envelope) = run_rollback_subprocess(tmp.path(), &[]);
1969+
assert_eq!(code, 0, "rollback must succeed: {envelope}");
1970+
assert_eq!(envelope["status"], "success", "{envelope}");
1971+
assert!(
1972+
warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()),
1973+
"{envelope}"
1974+
);
1975+
assert_eq!(
1976+
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
1977+
BERRY_PKG_RETIRED
1978+
);
1979+
assert_eq!(
1980+
std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(),
1981+
lock,
1982+
"the lock is not touched"
1983+
);
1984+
// Nothing hosted is left: list is clean.
1985+
let (code, envelope) = run_cli_json(tmp.path(), &["list"]);
1986+
assert_eq!(code, 0, "{envelope}");
1987+
assert!(warning_codes(&envelope).is_empty(), "{envelope}");
1988+
}
1989+
1990+
#[test]
1991+
#[serial]
1992+
fn remove_retires_a_berry_selector_left_by_yarn_remove() {
1993+
for target in [LP_UUID, LP_PURL] {
1994+
let tmp = tempfile::tempdir().unwrap();
1995+
write_berry_selector_left_by_yarn_remove(tmp.path());
1996+
let (code, envelope) = run_cli_json(tmp.path(), &["remove", target, "--yes"]);
1997+
assert_eq!(code, 0, "remove {target} must succeed: {envelope}");
1998+
assert_eq!(
1999+
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
2000+
BERRY_PKG_RETIRED,
2001+
"remove {target}"
2002+
);
2003+
}
2004+
}

0 commit comments

Comments
 (0)