From ce8daac65b46ed6c37b4eeb9e5874fed69497317 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:28:28 -0400 Subject: [PATCH 1/8] Correct two false CLI_CONTRACT claims The rollback prompt's decline line is the shared "Cancelled; no changes made." (ui::CANCELLED), not "Rollback cancelled.". The JSON envelope shape is pinned by json_envelope.rs's unit tests and the commands' e2e tests; tests/cli_parse_*.rs pin the parsed clap arguments, not the envelope. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7eddb127c..9632ea389 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -904,7 +904,7 @@ A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatc 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — blob, diff and legacy package-archive sweeps against the post-removal manifest, using the same artifact-reference policy as `remove`, retaining beforeHash blobs for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Cancelled; no changes made.` (stdout) and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) @@ -1133,7 +1133,7 @@ The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET ## JSON output shapes -Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth and `tests/cli_parse_*.rs` for snapshot tests that lock the shape. +Every `--json` invocation emits a single JSON object that follows the **unified envelope** below. The envelope was introduced in v3.0; older per-command shapes are deprecated. See `src/json_envelope.rs` for the source of truth; its unit tests pin the serialized names, and each command's e2e tests assert the envelope it emits. The `tests/cli_parse_*.rs` files pin the parsed clap arguments, not this shape (a few, such as `cli_parse_list.rs`, also spot-check `list`'s envelope). ### Envelope shape From caa946fe8325ca0971418d7b856ddd5b8ff26938 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:28:29 -0400 Subject: [PATCH 2/8] Drop comments describing the deleted hosted ledger and replay v5 hosted mode keeps no ledger, and patch/redirect has no ledger, replay or takeover module any more. The hosted engine, the CLI hosted flow and the staged I/O layer still described a ledger load, a merge-then-persist, a ledger-record fallback for the gem stale-install probe and edit ordering for a whole-ledger replay. Say what the code does now: the vendored ledger is the only one the hosted flow loads, the gem probe judges only this run's fetched records, and staged.rs serves the hosted -> upstream restore. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 49 +++++++++---------- crates/socket-patch-core/src/hosted/engine.rs | 16 +++--- .../src/patch/redirect/staged.rs | 17 +++---- 3 files changed, 39 insertions(+), 43 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e28629891..fe4eb5896 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -137,8 +137,9 @@ fn refuse( } /// The apply lock for a WET hosted run: the same `/apply.lock` -/// `apply`/`rollback`/`remove`/`vendor` hold, so the takeover pre-reverts, -/// the ledger merge and the lockfile writes never race them. `acquire` +/// `apply`/`rollback`/`remove`/`vendor` hold, so the takeover pre-reverts +/// (lockfiles + the vendored ledger) and the lockfile writes never race +/// them. `acquire` /// creates a missing `.socket/` and the guard's drop unlinks the lock file /// and prunes an otherwise-empty `.socket/`, so a run that ends up writing /// nothing leaves no residue. Contention / IO failures render through the @@ -299,11 +300,10 @@ async fn installed_stale_positive_evidence( /// refuses as a write root is still READ here /// (`verification_only_gem_paths`): bundler installs into it. /// * Records are found BY UUID (the fetch key, stable across purl -/// spellings): this run's fetched records first, then the redirect -/// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed -/// transiently still re-fires from the ledger instead of silently -/// dropping the warning (`record_fetch_failed` covers the fetch failure -/// itself). Record availability is part of the candidate filter, and the +/// spellings) among this run's fetched records; v5 keeps no hosted +/// ledger to fall back on, so a uuid whose `/patches/view` fetch failed +/// is not judged (`record_fetch_failed` surfaces that failure). Record +/// availability is part of the candidate filter, and the /// probe returns before any crawler work (or `gem env` subprocess spawn) /// when no judgment is possible. /// * PATCHED means [`verify_patch_record`] `Ok` — the one shared oracle @@ -326,8 +326,7 @@ async fn gem_stale_install_warnings( global: bool, global_prefix: Option, confirmed: &[(String, String)], - // This run's fetched records MERGED with the ledger's persisted ones - // (the caller hands the post-merge ledger map). + // This run's fetched records, by uuid. records: &std::collections::BTreeMap, gem_artifact_shas: &std::collections::BTreeMap<(String, String), String>, ) -> StaleInstallOutcome { @@ -629,20 +628,20 @@ pub(super) async fn run_redirect( /// ([`socket_patch_core::hosted::engine`], over a /// [`ProjectView::Disk`](socket_patch_core::vendor::lock_inventory::ProjectView)); /// what stays here is what needs the host: reference grants and the other -/// network fetches, the apply lock (wet runs with a grant), the redirect -/// ledger load, the vendored→hosted takeover pre-revert (symlink-checked -/// first), the `pipenv --version` probe, the symlink guard, the ledger -/// merge-then-persist and the file writes, the gem / Python / vlt +/// network fetches, the apply lock (wet runs with a grant), the +/// vendored→hosted takeover pre-revert (symlink-checked first), the +/// `pipenv --version` probe, the symlink guard, the file writes, the gem / +/// Python / vlt /// stale-install probes, and the optional VEX. Shared VERBATIM by `scan /// --mode hosted` (its `--json` arm through the `run_redirect` wrapper, its /// human arm through [`boxed_run_redirect_selected`] in `scan/mod.rs`; both /// select via `discover_selected`, with no prompt) and by `get --mode /// hosted` (which pins the advisory-resolved uuid), so all produce -/// identical on-disk results for the same selection. The redirect ledger -/// is loaded HERE, under the apply lock whenever this run holds one (never -/// handed in pre-loaded: a copy read before the lock could merge over a -/// concurrent writer's edits); a dry run or a zero-grant run reads it -/// strictly but writes nothing, quarantine included. +/// identical on-disk results for the same selection. v5 hosted mode keeps +/// no ledger (the lockfiles are the only record); the VENDORED ledger the +/// takeover needs is loaded HERE, under the apply lock whenever this run +/// holds one (never handed in pre-loaded: a copy read before the lock could +/// be saved over a concurrent writer's edits). /// /// `scan_result` must be `Some` exactly when `common.json` is set (the /// human/JSON split keys on `common.json`; a `--json` caller passing `None` @@ -1091,10 +1090,10 @@ pub(crate) async fn run_redirect_selected( std::collections::BTreeMap::new(); let mut record_warnings: Vec = Vec::new(); - // SYMLINK GUARD (see `engine::guard`) — before the ledger and before any - // write, dry runs included, so a dry run predicts the refusal. The - // revert side (replay.rs) already refuses linked files, so the write - // side must too. + // SYMLINK GUARD (see `engine::guard`) — before any write, dry runs + // included, so a dry run predicts the refusal. The revert side (the + // hosted → upstream restore's staged flush) already refuses linked + // files, so the write side must too. if let Some(refusal) = engine::guard(&view, &done, &candidates) { return refuse(common, scan_result.take(), &refusal); } @@ -1176,8 +1175,8 @@ pub(crate) async fn run_redirect_selected( // the writes so the warning describes the project as this run leaves it. // Idempotent re-scans re-confirm and re-probe, so the warning keeps // firing until the stale materialization is actually gone. Skipped - // EXPLICITLY on --dry-run: the probe's ledger-record fallback would - // otherwise judge state the run did not (re)create. + // EXPLICITLY on --dry-run: nothing was written, so the probe would + // judge state the run did not (re)create. let gem_stale: StaleInstallOutcome = if common.dry_run { StaleInstallOutcome::default() } else { @@ -1330,7 +1329,7 @@ pub(crate) async fn run_redirect_selected( // Stale-flagged purls are EXCLUDED from assume_applied: the same-run // envelope carries a redirect_gem_stale_install warning proving the // installed materialization unpatched, so attesting that purl from - // the ledger would contradict the run's own warning. Excluded purls + // this run's records would contradict the run's own warning. Excluded purls // fall back to `vex`'s normal installed-tree verification. // A confirmed uuid whose bundled instance the rewriter had to skip // (#469) leaves that copy unpatched, so it too is verified, never diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 161c7d3d4..91d2662e4 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -8,7 +8,7 @@ //! 1. [`build_candidates`] — reference grants → rewriter overrides. //! 2. [`bun_lockb_symlinked`] — the binary-lock symlink refusal. //! 3. vlt artifact preflight ([`super::vlt`]) + [`withhold_everywhere`]. -//! 4. (caller) the apply lock, the ledger, the vendored→hosted takeover. +//! 4. (caller) the apply lock and the vendored→hosted takeover. //! 5. [`read_candidate_files`] → [`wheel_targets`] → (caller) wheel metadata, //! and [`yarn_berry_manifest_targets`] → (caller) served npm manifests. //! 6. [`rewrite`] — the rewriters, the pnpm `trustLockfile` and npm @@ -17,8 +17,7 @@ //! //! Nothing here writes, spawns, reads the environment or touches the //! network: every host effect (locking, probes, record fetches, the commit -//! of the rewritten files, the redirect ledger in [`super::ledger`]) stays -//! with the caller. +//! of the rewritten files) stays with the caller. use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -1233,14 +1232,13 @@ pub async fn rewrite( ); if let Some((text, edit)) = trust_config_write { rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); - // Appended last: `--revert` walks edits in reverse, so the trust key - // is unwound before the lock originals are restored. + // Appended last, after the lock edits it serves. v5 keeps no hosted + // ledger, so nothing replays these edits; the order is write order. rewrite.edits.push(edit); } if let Some((text, edit)) = npmrc_config_write { rewrite.files.insert(NPMRC_REL.to_string(), text); - // Appended after the lock edits for the same reason: a whole-ledger - // replay unwinds the setting before the lock originals it served. + // Appended after the lock edits, like the pnpm trust key above. rewrite.edits.push(edit); } let rewritten: Vec = rewrite @@ -1903,8 +1901,8 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { .then_some("pypi") } -/// SYMLINK GUARD — fail-closed, whole rewrite, before the ledger and before -/// any write (hosted rewrites are transactional). The writer stages next to +/// SYMLINK GUARD — fail-closed, whole rewrite, before any write (hosted +/// rewrites are transactional). The writer stages next to /// the path and renames over it, which REPLACES a symbolic link with a /// detached regular copy: the link target goes stale and a revert restores /// bytes but never the link. Applies to every ecosystem's files and to dry diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index d57675577..c632f9391 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -1,13 +1,12 @@ -//! Staged, fail-closed file I/O shared by the hosted-redirect reverts — the -//! per-purl takeover ([`super::takeover`]) and the whole-ledger replay -//! ([`super::replay`]). +//! Staged, fail-closed file I/O for the hosted → upstream restore +//! ([`super::upstream`]). //! -//! Both reverts resolve every inverse against a STAGED view of the project -//! and let nothing reach disk until all of them have resolved, so a drift -//! refusal leaves the project byte-identical. This module is that staging -//! layer: FIFO-safe reads of untrusted project files, the staged view, and -//! one flush with the same guards on both sides (a symlink or FIFO squatting -//! a path refuses; every write is atomic and keeps the file's mode). +//! The restore resolves every pin against a STAGED view of the project and +//! lets nothing reach disk until all of them have resolved, so a refusal +//! leaves the project byte-identical. This module is that staging layer: +//! FIFO-safe reads of untrusted project files, the staged view, and one +//! flush with the same guards on both sides (a symlink or FIFO squatting a +//! path refuses; every write is atomic and keeps the file's mode). use std::collections::BTreeMap; use std::path::Path; From f216b6f2bf400d51712661703aada690ee120308 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:28:29 -0400 Subject: [PATCH 3/8] Fix three stale code comments - update/release.rs: the API client is no longer timeout-free; contrast self-update's whole-request deadlines with ApiTimeouts' connect and per-read bounds instead. - vex/discover/pypi_other.rs: is_socket_hosted_reference was deleted by #572; name hosted_pypi_reference, which shares the path parser and the origin allowlist. - scan/policy.rs: dir_markers falls back to manifests when a directory has no lock marker, which the in-memory engine does not do. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/scan/policy.rs | 5 ++++- crates/socket-patch-core/src/update/release.rs | 5 +++-- crates/socket-patch-core/src/vex/discover/pypi_other.rs | 9 +++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/policy.rs b/crates/socket-patch-cli/src/commands/scan/policy.rs index 98b1ecc45..5f5fe8afe 100644 --- a/crates/socket-patch-cli/src/commands/scan/policy.rs +++ b/crates/socket-patch-cli/src/commands/scan/policy.rs @@ -69,7 +69,10 @@ pub(crate) fn load_invocation_policy(args: &ScanArgs) -> Result Vec { let mut markers: Vec = std::fs::read_dir(dir) .map(|entries| { diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index 5b2869012..aa6518bb9 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -13,8 +13,9 @@ //! for a fallback that only fires when the redirect shape drifts. //! //! All fetch sizes are capped and every request carries an explicit -//! timeout: a hung self-update is strictly worse than a hung scan, so this -//! module does not inherit the API client's no-timeout posture. +//! whole-request deadline ([`UpdateTimeouts`]), unlike the API client's +//! connect + per-read bounds (`api::retry::ApiTimeouts`): a hung +//! self-update is strictly worse than a hung scan. use std::time::Duration; diff --git a/crates/socket-patch-core/src/vex/discover/pypi_other.rs b/crates/socket-patch-core/src/vex/discover/pypi_other.rs index 2196a1882..4372d887e 100644 --- a/crates/socket-patch-core/src/vex/discover/pypi_other.rs +++ b/crates/socket-patch-core/src/vex/discover/pypi_other.rs @@ -16,10 +16,11 @@ //! a url naming another package, is diagnosed, never trusted. When the //! url does not carry the `patch/pypi/…` levels (a self-hosted //! `--patch-server-url` layout), the artifact filename alone supplies -//! the version. This is the host-allowlisted twin of -//! `vendor::pypi_pipenv::is_socket_hosted_reference`, which accepts the -//! same path shape on ANY https host because it only decides ownership -//! of a lock entry, not attestation; +//! the version. The path is parsed by the same +//! `vendor::lock_inventory::pypi::hosted_artifact_url` that +//! `hosted_pypi_reference` (hosted Pipenv rotation and the vendored +//! Pipenv guard's "is this lock entry ours" check) uses, and both apply +//! the same patch-server origin allowlist; //! * a root-anchored `.socket/vendor/pypi//` path //! ([`vendor_ref`]) → [`WiringMode::Vendored`]. The wheel must be a single //! PEP 427 filename (or a server sdist's `dist-version.tar.gz`) naming the From 333acc4394749e602aa8fb196ca50b8d65e60cec Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:28:35 -0400 Subject: [PATCH 4/8] Report a signal-killed CLI as 128 + signal in the npm wrapper spawnSync gives status null when the binary dies by a signal, and the wrapper turned that into exit 1, so Ctrl-C read as an ordinary failure. Return 128 + the signal number, as a shell does (SIGINT 130, SIGTERM 143). Co-Authored-By: Claude Opus 5.5 (1M context) --- npm/socket-patch/bin/socket-patch | 11 +++++- npm/socket-patch/bin/socket-patch.test.mjs | 41 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/npm/socket-patch/bin/socket-patch b/npm/socket-patch/bin/socket-patch index 0012586f6..1e686b8b7 100755 --- a/npm/socket-patch/bin/socket-patch +++ b/npm/socket-patch/bin/socket-patch @@ -1,6 +1,7 @@ #!/usr/bin/env node const { spawnSync } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const PLATFORMS = { @@ -46,6 +47,14 @@ function orderCandidates(candidates, libc) { // installed candidate is worth trying. const UNRUNNABLE = new Set(["ENOENT", "EACCES", "ENOEXEC"]); +// A binary killed by a signal has no exit status; report it the way a +// shell does (128 + the signal number), so Ctrl-C reads as 130, not 1. +function exitCode({ status, signal }) { + if (status !== null && status !== undefined) return status; + const number = signal && os.constants.signals[signal]; + return number ? 128 + number : 1; +} + function runFirstUsable(binPaths, args, { spawn = (bin, argv) => spawnSync(bin, argv, { stdio: "inherit", env: process.env }), log = (msg) => console.error(msg), @@ -53,7 +62,7 @@ function runFirstUsable(binPaths, args, { let lastError; for (const bin of binPaths) { const result = spawn(bin, args); - if (!result.error) return result.status ?? 1; + if (!result.error) return exitCode(result); lastError = { bin, error: result.error }; if (!UNRUNNABLE.has(result.error.code)) break; } diff --git a/npm/socket-patch/bin/socket-patch.test.mjs b/npm/socket-patch/bin/socket-patch.test.mjs index b53f347ef..f41a9c99c 100644 --- a/npm/socket-patch/bin/socket-patch.test.mjs +++ b/npm/socket-patch/bin/socket-patch.test.mjs @@ -165,6 +165,20 @@ describe("npm wrapper libc selection (#974)", () => { assert.equal(status, 3); }); + // A binary killed by a signal has `status: null`; the wrapper must + // report it the way a shell does (128 + signal number), not as 1. + for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGKILL", 137]]) { + it(`reports a ${signal} death as exit ${code}`, () => { + const logs = []; + const status = wrapper.runFirstUsable(["/gnu", "/musl"], [], { + spawn: () => ({ status: null, signal }), + log: (msg) => logs.push(msg), + }); + assert.equal(status, code); + assert.deepEqual(logs, []); + }); + } + // End to end: a node_modules tree like yarn classic leaves on Alpine, // with both platform packages installed and the gnu binary unable to // start. The wrapper must run the musl binary instead of exiting 1 @@ -204,3 +218,30 @@ describe("npm wrapper libc selection (#974)", () => { }, ); }); + +describe("npm package contents", () => { + const pkgDir = join(__dirname, ".."); + const npm = spawnSync("npm", ["--version"], { encoding: "utf8" }); + + it( + "publishes the wrapper and the compiled schema, not sources or tests", + { skip: npm.status !== 0 && "npm is not on PATH" }, + () => { + const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: pkgDir, + encoding: "utf8", + }); + assert.equal(result.status, 0, `stderr: ${result.stderr}`); + const files = JSON.parse(result.stdout)[0].files.map((f) => f.path); + assert.ok(files.includes("bin/socket-patch"), files.join(", ")); + assert.ok(files.includes("package.json"), files.join(", ")); + for (const file of files) { + assert.doesNotMatch(file, /\.test\.|^src\/|tsconfig|tsbuildinfo/, `unexpected file in the tarball: ${file}`); + assert.ok( + file === "package.json" || file === "README.md" || file.startsWith("bin/socket-patch") || /^dist\/schema\/manifest-schema\.(js|d\.ts)$/.test(file), + `unexpected file in the tarball: ${file}`, + ); + } + }, + ); +}); From ea08b3c21f6cb582a14e876984a782d1eae36179 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:28:35 -0400 Subject: [PATCH 5/8] Keep the manifest setup block in the npm schema and run its tests - The zod PatchManifestSchema lacked the legacy setup block the Rust manifest still parses and keeps, so validating a pre-v5 manifest stripped it. Add SetupConfigSchema (optional exclude/manual lists). - package.json had no files list, so the tarball shipped src/, the tsconfig and the compiled tests. Publish only the wrapper, bundled binaries and the compiled schema; a pack test guards the list. - The test script called pnpm in an npm-locked package and no workflow ran it. Use npm, and run it in the dispatch-tests job. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 10 ++++++++ npm/socket-patch/package.json | 8 ++++++- .../src/schema/manifest-schema.test.ts | 24 +++++++++++++++++++ .../src/schema/manifest-schema.ts | 14 +++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b14d8d4..35972312d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -800,6 +800,16 @@ jobs: - name: Run npm dispatch tests run: node --test npm/socket-patch/bin/socket-patch.test.mjs + - name: Run npm schema tests + # The `./schema` export's zod tests. `npm install --no-save`, not + # `npm ci`, for the reason publish-npm.yml gives (a version-synced + # lock can name platform packages not yet on the registry); the + # platform binaries are optional and not needed here. + working-directory: npm/socket-patch + run: | + npm install --no-save --ignore-scripts --no-audit --no-fund --omit=optional + npm test + # Compiles the CLI and every CLI test target once per OS (--all-features, # so this is also the feature-gated suites' compile-rot check) and uploads # the binaries the e2e, e2e-full and cargo-vex legs run. The legs run the diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index 0be711401..91514a39f 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -5,6 +5,12 @@ "bin": { "socket-patch": "bin/socket-patch" }, + "files": [ + "bin/socket-patch", + "bin/socket-patch-*", + "dist/schema/manifest-schema.js", + "dist/schema/manifest-schema.d.ts" + ], "exports": { "./schema": { "types": "./dist/schema/manifest-schema.d.ts", @@ -18,7 +24,7 @@ "scripts": { "build": "tsc", "prepack": "tsc", - "test": "pnpm run build && node --test dist/**/*.test.js" + "test": "npm run build && node --test dist/schema/manifest-schema.test.js" }, "keywords": [ "security", diff --git a/npm/socket-patch/src/schema/manifest-schema.test.ts b/npm/socket-patch/src/schema/manifest-schema.test.ts index d87a0f5cf..12ee44062 100644 --- a/npm/socket-patch/src/schema/manifest-schema.test.ts +++ b/npm/socket-patch/src/schema/manifest-schema.test.ts @@ -122,6 +122,30 @@ describe('PatchManifestSchema', () => { assert.ok(!result.success, 'Invalid UUID should fail') }) + it('should keep the legacy setup block on round-trip', () => { + const manifest = { + patches: {}, + setup: { exclude: ['packages/legacy'], manual: ['pypi'] }, + } + const result = PatchManifestSchema.safeParse(manifest) + assert.ok(result.success, 'A manifest with a setup block should parse') + assert.deepEqual(result.data.setup, manifest.setup) + }) + + it('should accept a setup block with either list omitted', () => { + const result = PatchManifestSchema.safeParse({ patches: {}, setup: {} }) + assert.ok(result.success, 'An empty setup block should parse') + assert.deepEqual(result.data.setup, {}) + }) + + it('should reject a malformed setup block', () => { + const result = PatchManifestSchema.safeParse({ + patches: {}, + setup: { exclude: 'packages/legacy' }, + }) + assert.ok(!result.success, 'setup.exclude must be a list') + }) + it('should reject non-object input', () => { assert.ok(!PatchManifestSchema.safeParse(null).success) assert.ok(!PatchManifestSchema.safeParse('string').success) diff --git a/npm/socket-patch/src/schema/manifest-schema.ts b/npm/socket-patch/src/schema/manifest-schema.ts index e11f29361..5572fa677 100644 --- a/npm/socket-patch/src/schema/manifest-schema.ts +++ b/npm/socket-patch/src/schema/manifest-schema.ts @@ -28,11 +28,25 @@ export const PatchRecordSchema = z.object({ export type PatchRecord = z.infer +// Legacy state written by the `setup` command that v5 removed (and by the +// pre-v5 `vex`). The CLI still parses it and keeps it on rewrite, so a +// manifest validated here keeps it too. Mirrors `SetupConfig` in +// crates/socket-patch-core/src/manifest/schema.rs. +export const SetupConfigSchema = z.object({ + // Workspace-member paths the removed `setup` skipped. + exclude: z.array(z.string()).optional(), + // Ecosystems the pre-v5 `vex` attested with no install hook wired. + manual: z.array(z.string()).optional(), +}) + +export type SetupConfig = z.infer + export const PatchManifestSchema = z.object({ patches: z.record( z.string(), // Package PURL like "pkg:npm/simplehttpserver@0.0.6" PatchRecordSchema, ), + setup: SetupConfigSchema.optional(), }) export type PatchManifest = z.infer From 927aef655c694aed8db16fbf9bcf85d1c2915e85 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:24:27 -0400 Subject: [PATCH 6/8] Drop the hosted ledger story from the stale-install probe tests The test helper and one test still described the merged ledger map the deleted hosted ledger used to feed. Production now hands over only this run's fetched records; the renamed test pins what it really checks: a record keyed under another purl spelling still matches by uuid. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/scan/hosted.rs | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index fe4eb5896..2db2eab60 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3381,8 +3381,8 @@ mod tests { /// Probe invocation with the default surface (project-local discovery, /// no artifact shas) — tests override the knobs they exercise. - /// `records` is the merged map production hands over (this run's - /// fetched records plus the ledger's persisted ones). + /// `records` is the map production hands over: this run's fetched + /// records, by uuid. async fn probe( cwd: &std::path::Path, confirmed: &[(String, String)], @@ -3711,25 +3711,22 @@ mod tests { ); } - /// RE-FIRE guarantee: when this run's record fetch failed (no fresh - /// records), the merged map the caller hands over still carries the - /// redirect ledger's PERSISTED record under whatever purl key the - /// ledger used — and the probe's uuid lookup judges from it, so a - /// transient /patches/view failure cannot silently retire the warning - /// while the stale materialization is still there. + /// The probe links a record to a confirmed purl by uuid alone: a + /// record keyed under the API's qualified purl spelling (not the + /// confirmed purl) must still judge the stale materialization. #[tokio::test] - async fn gem_stale_probe_judges_from_persisted_ledger_records() { + async fn gem_stale_probe_matches_records_by_uuid_not_purl_key() { let stale = tempfile::tempdir().unwrap(); materialize_gem(stale.path(), GEM_UPSTREAM); - // Persisted under the API's qualified spelling, not the confirmed + // Keyed under the API's qualified spelling, not the confirmed // purl: only the uuid links them. - let mut ledger_only = std::collections::BTreeMap::new(); - ledger_only.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); - let out = probe(stale.path(), &one_confirmed(), &ledger_only).await; + let mut qualified = std::collections::BTreeMap::new(); + qualified.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); + let out = probe(stale.path(), &one_confirmed(), &qualified).await; assert_eq!( out.warnings.len(), 1, - "the ledger records must keep the warning firing across flaky fetches" + "a record keyed under another purl spelling must still match by uuid" ); } From c45f5cc0c03ea26ef0f1832257c7f8a5645a1512 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:24:27 -0400 Subject: [PATCH 7/8] Require the compiled schema in the npm tarball once it is built The pack-contents test only allowlisted dist/schema files, so a files list that dropped them would still pass. When dist/schema is built, both manifest-schema.js and .d.ts must be in the pack list. Co-Authored-By: Claude Opus 5.5 (1M context) --- npm/socket-patch/bin/socket-patch.test.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/npm/socket-patch/bin/socket-patch.test.mjs b/npm/socket-patch/bin/socket-patch.test.mjs index f41a9c99c..02c69df75 100644 --- a/npm/socket-patch/bin/socket-patch.test.mjs +++ b/npm/socket-patch/bin/socket-patch.test.mjs @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -235,6 +235,12 @@ describe("npm package contents", () => { const files = JSON.parse(result.stdout)[0].files.map((f) => f.path); assert.ok(files.includes("bin/socket-patch"), files.join(", ")); assert.ok(files.includes("package.json"), files.join(", ")); + // The `./schema` export points at dist/; once it is built, both + // compiled files must ship or the export resolves to nothing. + if (existsSync(join(pkgDir, "dist", "schema", "manifest-schema.js"))) { + assert.ok(files.includes("dist/schema/manifest-schema.js"), files.join(", ")); + assert.ok(files.includes("dist/schema/manifest-schema.d.ts"), files.join(", ")); + } for (const file of files) { assert.doesNotMatch(file, /\.test\.|^src\/|tsconfig|tsbuildinfo/, `unexpected file in the tarball: ${file}`); assert.ok( From 6af73d3baf2beaae293acc70e8d71cdda1013aea Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:24:42 -0400 Subject: [PATCH 8/8] Rerun the npm pack test after the schema build in CI Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35972312d..dcb173795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -804,11 +804,14 @@ jobs: # The `./schema` export's zod tests. `npm install --no-save`, not # `npm ci`, for the reason publish-npm.yml gives (a version-synced # lock can name platform packages not yet on the registry); the - # platform binaries are optional and not needed here. + # platform binaries are optional and not needed here. The pack + # test runs again once `npm test` has built dist/, so it also + # checks that the compiled schema ships. working-directory: npm/socket-patch run: | npm install --no-save --ignore-scripts --no-audit --no-fund --omit=optional npm test + node --test --test-name-pattern="npm package contents" bin/socket-patch.test.mjs # Compiles the CLI and every CLI test target once per OS (--all-features, # so this is also the feature-gated suites' compile-rot check) and uploads