[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
A project that was patched in agent mode (for example by v4.0.0's default scan) keeps its .socket/manifest.json record when a v5 bare scan (hosted) takes the package over. That's fine as long as the API still offers the same patch. But if the patch has been superseded (same name@version, new UUID with different bytes) by the time the hosted scan runs:
- the hosted scan pins the new UUID B, reports
updates: [{oldUuid: A, newUuid: B}], and leaves manifest record A in place with no warning;
npm ci installs B's bytes;
vex sees the conflict and handles it (the recorded patch A is superseded by the lockfile-wired patch B; attesting only what the lockfile wires);
rollback restores the lock to upstream but then fails the agent leg on record A: Cannot roll back: package/index.js - File has been modified after patching, partial_failure, exit 1. Re-running rollback keeps exiting 1 until a reinstall happens to put the original bytes back.
remove pkg:npm/left-pad@1.3.0 fails the same way before the hosted leg runs: exit 1, and the lock still pins hosted patch B. The only way out is remove --skip-rollback (drops A only) followed by another remove, or a manual npm ci between rollbacks.
list shows two patches for the one purl (agent A and hosted B).
Impact
This is the main v4 → v5 upgrade path (v4 defaulted to agent mode, v5 defaults to hosted), combined with an ordinary patch update. After that, rollback and remove (the documented ways to unpatch) fail with an error that blames the user ("modified after patching"), and remove leaves the hosted pin live. If the patch isn't superseded, the same migration rolls back cleanly (exit 0), because the hosted bytes equal A's after-bytes.
Repro (Linux, npm 10.9.4; mock patch API serving left-pad@1.3.0 as UUID A, later replaced by UUID B)
API="--api-url $U --org o --api-token fake --patch-server-url $U"
mkdir p && cd p && echo '{"name":"p","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > package.json && npm install
# API offers A
socket-patch scan --mode agent --yes $API # or v4.0.0's bare `scan`; manifest records A
# API now offers only B (supersedes A)
socket-patch scan --yes $API # hosted: exit 0, updates[] A→B, lock pins B, manifest still A
rm -rf node_modules && npm ci # installs B's bytes
socket-patch rollback --yes $API # lock restored, then: "Failed to roll back: … File has been modified after patching" exit 1
socket-patch rollback --yes $API # exit 1 again
# on a copy taken before rollback:
socket-patch remove pkg:npm/left-pad@1.3.0 --yes $API # exit 1, lock still pins B
Expected vs actual
- CLI_CONTRACT "Rollback command contract": rollback moves the project "toward fully unpatched" across the agent, vendored and hosted legs, and exit 1 is for things that leave "the system still patched". Here the only thing left is a manifest record that the hosted scan itself superseded.
vex already treats it as superseded (vex_record_superseded, vex_sources.rs:359), but rollback and remove don't.
- Expected: either the hosted scan that re-pins the purl to B drops or flags the superseded manifest record A (a warning such as
hosted_wiring_retained, which agent mode gives in the reverse direction), or rollback/remove treat a manifest record superseded by a live hosted pin as handled by the hosted leg. remove should never exit with the hosted pin still live and only --skip-rollback as a remedy.
- Actual: exit 1 on every rollback until a reinstall, and remove refuses with the hosted pin kept.
Matrix (Linux; real npm installs, cold cache)
| npm |
agent(A) → hosted(B), rollback |
remove |
agent(A) → hosted(A) control |
| 8.19.4 (Node 22) |
exit 1 (lock byte-exact) |
— |
— |
| 10.9.4 (Node 22) |
exit 1 ×2; exit 0 after npm ci |
exit 1, pin B kept |
rollback exit 0 |
| 12.2.0 (Node 24) |
exit 1 (lock byte-exact) |
— |
— |
| v4.0.0 agent manifest → main hosted (npm 10) |
exit 1 |
— |
— |
Other superseding cells passed: hosted→hosted, vendored→vendored and agent→agent re-pin to B on npm 8/10/12 (plain and alias); npm ci installs B; vex attests B; rollback is byte-exact. Cross-mode hosted→vendored, vendored→hosted and agent→vendored with superseding also pass.
First bad
Not bisected: v5 hosted-by-default exists only on main (9c43dfc, which still reports 4.0.0). Reproduces on main 9c43dfc.
Suspect code
crates/socket-patch-cli/src/commands/rollback.rs:1436: the agent leg runs over every manifest record, including one superseded by a live hosted pin (the hosted leg ran first and succeeded).
crates/socket-patch-core/src/patch/rollback.rs:172: HashMismatch "modified after patching" when the file holds the superseding patch's bytes.
crates/socket-patch-cli/src/commands/remove.rs (~587): the agent rollback failure aborts before the hosted leg.
- Hosted scan (
commands/scan): doesn't reconcile or warn about a manifest record for a purl it re-pins to a different UUID. Compare vex_sources.rs:359 (vex_record_superseded).
No probe runs: probe branches are on hold for this routine until stale branches can be deleted.
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
A project that was patched in agent mode (for example by v4.0.0's default
scan) keeps its.socket/manifest.jsonrecord when a v5 barescan(hosted) takes the package over. That's fine as long as the API still offers the same patch. But if the patch has been superseded (samename@version, new UUID with different bytes) by the time the hosted scan runs:updates: [{oldUuid: A, newUuid: B}], and leaves manifest record A in place with no warning;npm ciinstalls B's bytes;vexsees the conflict and handles it (the recorded patch A is superseded by the lockfile-wired patch B; attesting only what the lockfile wires);rollbackrestores the lock to upstream but then fails the agent leg on record A:Cannot roll back: package/index.js - File has been modified after patching,partial_failure, exit 1. Re-runningrollbackkeeps exiting 1 until a reinstall happens to put the original bytes back.remove pkg:npm/left-pad@1.3.0fails the same way before the hosted leg runs: exit 1, and the lock still pins hosted patch B. The only way out isremove --skip-rollback(drops A only) followed by anotherremove, or a manualnpm cibetween rollbacks.listshows two patches for the one purl (agent A and hosted B).Impact
This is the main v4 → v5 upgrade path (v4 defaulted to agent mode, v5 defaults to hosted), combined with an ordinary patch update. After that,
rollbackandremove(the documented ways to unpatch) fail with an error that blames the user ("modified after patching"), andremoveleaves the hosted pin live. If the patch isn't superseded, the same migration rolls back cleanly (exit 0), because the hosted bytes equal A's after-bytes.Repro (Linux, npm 10.9.4; mock patch API serving left-pad@1.3.0 as UUID A, later replaced by UUID B)
Expected vs actual
vexalready treats it as superseded (vex_record_superseded,vex_sources.rs:359), but rollback and remove don't.hosted_wiring_retained, which agent mode gives in the reverse direction), or rollback/remove treat a manifest record superseded by a live hosted pin as handled by the hosted leg.removeshould never exit with the hosted pin still live and only--skip-rollbackas a remedy.Matrix (Linux; real npm installs, cold cache)
npm ciOther superseding cells passed: hosted→hosted, vendored→vendored and agent→agent re-pin to B on npm 8/10/12 (plain and alias);
npm ciinstalls B;vexattests B; rollback is byte-exact. Cross-mode hosted→vendored, vendored→hosted and agent→vendored with superseding also pass.First bad
Not bisected: v5 hosted-by-default exists only on main (
9c43dfc, which still reports 4.0.0). Reproduces on main9c43dfc.Suspect code
crates/socket-patch-cli/src/commands/rollback.rs:1436: the agent leg runs over every manifest record, including one superseded by a live hosted pin (the hosted leg ran first and succeeded).crates/socket-patch-core/src/patch/rollback.rs:172:HashMismatch"modified after patching" when the file holds the superseding patch's bytes.crates/socket-patch-cli/src/commands/remove.rs(~587): the agent rollback failure aborts before the hosted leg.commands/scan): doesn't reconcile or warn about a manifest record for a purl it re-pins to a different UUID. Comparevex_sources.rs:359(vex_record_superseded).No probe runs: probe branches are on hold for this routine until stale branches can be deleted.