Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**npm hosted-mode `replace-registry-host` contract**: npm >=8 rewrites the origin of a lock's `resolved` URL to the configured registry when its `replace-registry-host` setting is `always`, or equals the URL's hostname (`npmjs`, the default, stands for `registry.npmjs.org`; `never` matches nothing). A hosted pin rewritten that way is fetched from `<registry>/patch/npm/…` and every `npm ci` / `npm install` fails E404 (closed: never unpatched bytes; a warm npm cache can hide it locally). Whenever a run leaves a root npm lock carrying a granted hosted artifact URL (the same trigger as the `allow-remote` contract, so an "already on hosted patches" re-run reports it too), the CLI resolves `replace-registry-host` across npm's layers — an `npm_config_replace_registry_host` environment variable (any spelling npm normalizes), then the project `.npmrc`, then the user / global / builtin config files (located exactly like the `allow-remote` layers), its value env-replaced the way npm reads it (`${VAR}` / `${VAR?}`: `replace-registry-host=${RRH}` with `RRH=always` counts as `always`, and the warning quotes both spellings; an unset `${VAR}` stays literal, as in npm) — and, when the effective value rewrites a pinned host, emits the `redirect_npm_replace_registry_host` warning naming the layer (env var / project file / config path) and the remedies: `replace-registry-host=npmjs` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm never rewrites. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged.

**npm hosted-mode `omit-lockfile-registry-resolved` contract (#1871)**: npm >=8 with `omit-lockfile-registry-resolved` true drops `resolved` from every registry-spec dependency each time it saves the lock (any `npm install`, even one that changes nothing), whatever the URL's host, and keeps `integrity`. A hosted pin is then left with the patched sha512 but no hosted URL, so npm fetches the registry tarball and every install without a warm cache fails EINTEGRITY (closed: never unpatched bytes), and `vex` no longer finds the hosted reference. On the same trigger as the `replace-registry-host` contract, the CLI resolves `omit-lockfile-registry-resolved` across the same layers (an `npm_config_omit_lockfile_registry_resolved` environment variable, then the project `.npmrc`, then the user / global / builtin config files, env-replaced the same way) and reads the value as npm does (`@npmcli/config`'s `parseField`, then nopt's Boolean: a bare key or an empty value is true, `false` / `null` / `undefined` as written are false, and otherwise any env-replaced value but `false`, `null` or a numeric zero is true). When it is true, the run emits the `redirect_npm_omit_lockfile_registry_resolved` warning naming the layer and the remedies: `omit-lockfile-registry-resolved=false` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm keeps. A hosted re-run re-pins a stripped lock. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged.

**pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` on pnpm 11 and newer, `npm_config_shared_workspace_lockfile` on pnpm 10 and older, either when the major is unknown (#1460, the major as for the `lockfile` setting below); booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan.

**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads the setting from its environment, in the one variable family that major reads (#1460): `pnpm_config_lockfile` / `pnpm_config_package_lock` on pnpm 11 and newer, `npm_config_lockfile` / `npm_config_package_lock` on pnpm 10 and older, either family when the major is unknown (so the `npm_config_package_lock=` that `npx` / `npm exec` export from an npm-era `.npmrc` refuses nothing on pnpm 11+). Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged.
Expand Down Expand Up @@ -1730,6 +1732,7 @@ command's integration tests check its output.
| `redirect_vlt_lockfile_version_missing` / `redirect_vlt_old_lockfile_ignored` / `redirect_vlt_scalar_registry_ignored` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): the lock has no `lockfileVersion` (vlt ≥ 1.0.0-rc.15 re-resolves it) / a legacy default-registry id without `"modifiers"` in `vlt.json` (vlt 0.0.0-16 … 0.0.0-24 ignore the lock) / a scalar `registry` option that vlt 1.0.0-rc.7 … rc.29 honor over the lock. The deps stay redirected, but the run's `--vex` does not attest them. |
| `redirect_vlt_sibling_lockfiles` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` and another npm-family lock are both present and vlt's install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is not, so both locks were rewritten and the other lock's rules confirm. |
| `redirect_npm_replace_registry_host` | `warnings[]` (warning) | scan/get `--mode hosted` (npm): the effective npm `replace-registry-host` (env var, project `.npmrc`, or user / global / builtin config) is `always` or a pinned hosted hostname, so npm rewrites the hosted pins to the configured registry and every install fails E404; the detail names the layer and the remedies (`replace-registry-host=npmjs` in the project `.npmrc`, or vendored mode). See the npm `replace-registry-host` contract. |
| `redirect_npm_omit_lockfile_registry_resolved` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #1871): the effective npm `omit-lockfile-registry-resolved` (env var, project `.npmrc`, or user / global / builtin config) is true, so npm's next lock save drops the hosted pins' `resolved` while keeping the patched `integrity` and every cold-cache install fails EINTEGRITY; the detail names the layer and the remedies (`omit-lockfile-registry-resolved=false` in the project `.npmrc`, or vendored mode). See the npm `omit-lockfile-registry-resolved` contract. |
| `redirect_npm_allow_scripts` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #1595): a root npm lock pins a patched package to its hosted artifact, a lock entry for it has `hasInstallScript`, and package.json `allowScripts` approves it only by version (`"pkg@x.y.z"` or `||`-joined exact versions) with no name-only approval and no deny. npm 12 matches a version pin only against a registry tarball URL, so the approved install script stops running (every install fails under `strict-allow-scripts`). The policy is never rewritten; the detail names the pinned keys and the name-only remedy. |
| `redirect_vlt_no_lockfile` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt.json` or vlt's install state is present without `vlt-lock.json`; replaces `redirect_npm_no_lockfile` for vlt projects. |
| `redirect_npm_shrinkwrap_only` | `warnings[]` (warning) | scan/get `--mode hosted` (npm, #899): the root `npm-shrinkwrap.json` is the only npm lock and carries a hosted redirect (this run's or an earlier one's — every run repeats it until the project gains a `package-lock.json`). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; npm <= 11 installs the patch. Rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. |
Expand Down
139 changes: 139 additions & 0 deletions crates/socket-patch-cli/tests/vendor/redirect_npm_allow_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream==";
const CODE: &str = "redirect_npm_allow_remote";
const LEFT: &str = "npm_allow_remote_left";
const REPLACE_HOST: &str = "redirect_npm_replace_registry_host";
const OMIT_RESOLVED: &str = "redirect_npm_omit_lockfile_registry_resolved";

fn hosted_url() -> String {
format!(
Expand Down Expand Up @@ -191,6 +192,14 @@ fn npm_isolation(root: &Path) -> Vec<(String, String)> {
("npm_config_allow_remote".into(), String::new()),
("NPM_CONFIG_REPLACE_REGISTRY_HOST".into(), String::new()),
("npm_config_replace_registry_host".into(), String::new()),
(
"NPM_CONFIG_OMIT_LOCKFILE_REGISTRY_RESOLVED".into(),
String::new(),
),
(
"npm_config_omit_lockfile_registry_resolved".into(),
String::new(),
),
]
}

Expand Down Expand Up @@ -1002,6 +1011,136 @@ async fn replace_registry_host_via_env_interpolation_is_warned() {
assert_eq!(replace_host_warning(&doc), None, "{doc:#}");
}

fn omit_resolved_warning(doc: &Value) -> Option<&str> {
doc["warnings"]
.as_array()
.into_iter()
.flatten()
.find(|w| w["code"] == OMIT_RESOLVED)
.and_then(|w| w["detail"].as_str())
}

/// #1871: npm >= 8's `omit-lockfile-registry-resolved=true` makes the next
/// lock save (even a no-op `npm install`) drop the hosted pin's `resolved`
/// while keeping its patched `integrity`, so every cold-cache install
/// fails EINTEGRITY and `vex` loses the hosted reference. The hosted run
/// said nothing and exited 0. It now warns
/// `redirect_npm_omit_lockfile_registry_resolved` (JSON + human), naming
/// the layer that sets it, on the first run and the "already hosted"
/// re-run; a value npm reads as false stays quiet.
#[tokio::test]
async fn omit_lockfile_registry_resolved_is_warned() {
let server = MockServer::start().await;
mock_api(&server).await;

// Project `.npmrc`: first run and the idempotent re-run both warn.
let tmp = tempfile::tempdir().unwrap();
write_npm_project(tmp.path(), "package-lock.json");
std::fs::write(
tmp.path().join(".npmrc"),
"omit-lockfile-registry-resolved=true\n",
)
.unwrap();
for run in 0..2 {
let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]);
assert_eq!(code, 0, "{doc:#}");
let detail = omit_resolved_warning(&doc)
.unwrap_or_else(|| panic!("run {run}: no {OMIT_RESOLVED}: {doc:#}"));
assert!(
detail.contains("patch.test")
&& detail
.contains("the project .npmrc sets `omit-lockfile-registry-resolved=true`")
&& detail.contains("EINTEGRITY")
&& detail.contains("omit-lockfile-registry-resolved=false")
&& detail.contains("--mode vendored"),
"{detail}"
);
assert!(allow_remote_warning(&doc).is_some(), "{doc:#}");
assert_eq!(replace_host_warning(&doc), None, "{doc:#}");
}
let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]);
assert_eq!(code, 0, "{stderr}");
assert!(
stderr.contains("Warning: ") && stderr.contains("`omit-lockfile-registry-resolved=true`"),
"{stderr}"
);

// npm reads the value through `parseField` + nopt's Boolean: a bare
// key, a blank value and any other string are true; `false`, `null`,
// `undefined` and `0` are not (measured on npm 10.9.4).
for (line, warns) in [
("omit-lockfile-registry-resolved", true),
("omit-lockfile-registry-resolved=1", true),
("omit-lockfile-registry-resolved=", true),
("omit-lockfile-registry-resolved=undefined", false),
("omit-lockfile-registry-resolved=false", false),
("omit-lockfile-registry-resolved=null", false),
("omit-lockfile-registry-resolved=0", false),
("[scope]\nomit-lockfile-registry-resolved=true", false),
] {
let tmp = tempfile::tempdir().unwrap();
write_npm_project(tmp.path(), "package-lock.json");
std::fs::write(tmp.path().join(".npmrc"), format!("{line}\n")).unwrap();
let (code, doc, _) = scan_hosted(tmp.path(), &server.uri(), &["--json"]);
assert_eq!(code, 0, "{doc:#}");
assert_eq!(
omit_resolved_warning(&doc).is_some(),
warns,
"{line}: {doc:#}"
);
}

// User config (relocated the way npm allows).
let tmp = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
write_npm_project(tmp.path(), "package-lock.json");
let user = cfg.path().join("user.npmrc");
std::fs::write(&user, "omit-lockfile-registry-resolved=true\n").unwrap();
let user_s = user.to_str().unwrap();
let user_env = [
("NPM_CONFIG_USERCONFIG", user_s),
("npm_config_userconfig", user_s),
];
let (code, doc, _) = scan_hosted_env(tmp.path(), &server.uri(), &["--json"], &user_env);
assert_eq!(code, 0, "{doc:#}");
let detail =
omit_resolved_warning(&doc).unwrap_or_else(|| panic!("no {OMIT_RESOLVED}: {doc:#}"));
assert!(
detail.contains("the user npm config")
&& detail.contains(user_s)
&& detail.contains("in the project .npmrc (it outranks the user config)"),
"{detail}"
);
// ...which a project `false` overrides, as it does for npm.
let tmp = tempfile::tempdir().unwrap();
write_npm_project(tmp.path(), "package-lock.json");
std::fs::write(
tmp.path().join(".npmrc"),
"omit-lockfile-registry-resolved=false\n",
)
.unwrap();
let (code, doc, _) = scan_hosted_env(tmp.path(), &server.uri(), &["--json"], &user_env);
assert_eq!(code, 0, "{doc:#}");
assert_eq!(omit_resolved_warning(&doc), None, "{doc:#}");

// The env var beats a project `false`.
let (code, doc, _) = scan_hosted_env(
tmp.path(),
&server.uri(),
&["--json"],
&[("npm_config_omit_lockfile_registry_resolved", "true")],
);
assert_eq!(code, 0, "{doc:#}");
let detail =
omit_resolved_warning(&doc).unwrap_or_else(|| panic!("no {OMIT_RESOLVED}: {doc:#}"));
assert!(
detail.to_ascii_lowercase().contains(
"npm_config_omit_lockfile_registry_resolved sets `omit-lockfile-registry-resolved=true`"
),
"{detail}"
);
}

/// Review finding: `remove` dropped the hosted leg's warnings. A
/// redirect-created `.npmrc` the user has since added to is no longer the
/// scaffold, so the restore keeps it byte-for-byte and warns that the
Expand Down
Loading
Loading