[agent] Found by the scheduled Poetry bug-hunt routine (ledger #311).
Summary
The agent-mode manifest (.socket/manifest.json) doesn't record which scope a patch was applied in. It could be the project (a Poetry venv) or a global install (-g / --global-prefix). rollback only crawls its own scope. When the copy it finds there is already original, it counts the patch as rolled back, removes the manifest entry and garbage-collects its blobs. The copy that is actually patched, in the other scope, is left patched, and the run exits 0 with status: success.
It happens in both directions, from inside a Poetry project:
| Applied with |
Rolled back with |
Result |
scan --mode agent (patches the project's .venv) |
rollback -g |
rolledBack: 0, alreadyOriginal: 1 (the global copy), manifest.removedEntries: ["pkg:pypi/six@1.16.0"], exit 0. The .venv stays patched. |
scan -g --mode agent (patches ~/.local/lib/python3.11/site-packages) |
rollback |
rolledBack: 0, alreadyOriginal: 1 (the .venv copy), removedEntries: ["pkg:pypi/six@1.16.0"], gc.removedBlobs: 1, exit 0. The global copy stays patched. |
Once the entry is gone, the right-scope rollback has nothing to do. In the second case, a later rollback -g reports rolledBack: 0, alreadyOriginal: 0 and exits 0, with the global copy still patched.
Impact
- The user is told the rollback succeeded, but a patched copy stays in place, and the only local record of it (manifest entry plus before-blobs) is deleted. Nothing on the machine can find or revert it any more, short of re-applying the same patch and rolling back in the right scope.
- This is easy to hit with Poetry. The same packages are commonly in the project venv and in the user site or system interpreter (the same
six, requests or urllib3), and -g from inside a project directory shares the project's .socket/manifest.json.
- It isn't specific to Poetry or pypi. The logic is ecosystem-agnostic (see the suspect code). It was reproduced here with a real Poetry venv and a real
pip install --user global copy.
Repro (Linux, Poetry 2.1.1, Python 3.11)
The mock patch API serves one agent patch for pkg:pypi/six@1.16.0: it appends # SOCKET-PATCHED to six.py, using the same routes as tests/vex_pypi_real_common/mod.rs plus /v0/orgs/<org>/patches/blob/<hash>. $A = --api-url http://127.0.0.1:18766 --api-token fake --org test-org --patch-server-url http://127.0.0.1:18766.
python3 -m pip install --user --ignore-installed six==1.16.0 # the global copy
U=~/.local/lib/python3.11/site-packages
mkdir proj && cd proj
cat > pyproject.toml <<'EOF'
[tool.poetry]
name = "gproj"
version = "0.1.0"
description = ""
authors = ["x <x@x>"]
package-mode = false
[tool.poetry.dependencies]
python = "^3.11"
six = "1.16.0"
EOF
poetry config virtualenvs.in-project true --local && poetry install
V=.venv/lib/python3.11/site-packages
# Direction 1: project apply, global rollback
socket-patch scan --mode agent --yes --json --ecosystems pypi $A # action "added"; $V/six.py patched
socket-patch rollback -g --json --ecosystems pypi $A # exit 0, alreadyOriginal 1, removedEntries [six]
tail -1 $V/six.py # SOCKET_PATCHED = 1 <- still patched
cat .socket/manifest.json # {"patches": {}}
# Direction 2: global apply, project rollback (restore both six.py files and rm -rf .socket first)
socket-patch scan -g --mode agent --yes --json --ecosystems pypi $A # $U/six.py patched
socket-patch rollback --json --ecosystems pypi $A # exit 0, alreadyOriginal 1, removedEntries [six], gc.removedBlobs 1
tail -1 $U/six.py # SOCKET_PATCHED = 1 <- still patched
socket-patch rollback -g --json --ecosystems pypi $A # exit 0, rolledBack 0: nothing left to revert
Expected vs actual
- Expected:
--global means "Operate on globally-installed packages" (CLI_CONTRACT.md, Global arguments), so rollback -g should affect global copies and the records that belong to them, and a project rollback should affect the project. The contract treats a copy that is already original / not installed as satisfying rollback's end state, because "rollback's job is 'make the tree unpatched'" (CLI_CONTRACT.md, JSON migration notes for rollback). Here the tree is not unpatched: the record's patched copy just sits outside the scope that was crawled. The entry should be kept (and its blobs pinned, as the crawler-miss guard already does for not_installed) unless every copy it was applied to is verified original. Alternatively, rollback could warn and exit non-zero.
- Actual: an
already_original copy in the current scope counts as success. The purl lands in succeeded_purls and is removed from the manifest, and GC sweeps the before-blobs (the crawler-miss pin covers only not_installed).
OS × version matrix
| OS |
Poetry |
main 2463257 |
PR #446 head 92c71ad |
release 4.0.0 |
| Linux |
2.1.1 |
repro (both directions, 2/2 each) |
repro (both directions, 2/2 each) |
not reproduced (entry kept, both directions, 2/2) |
macOS and Windows weren't run: this routine can't create probe branches this run. The logic has no OS-specific path.
First bad commit
d5e1815 (#231, "full-state rollback default"). Both directions, 2/2 runs each, on Linux with Poetry 2.1.1:
| Build |
Project apply + rollback -g |
-g apply + rollback |
release 4.0.0 (v4.0.0) |
entry kept |
entry kept |
d5e1815 (#231) |
entry removed, .venv still patched |
entry removed, global still patched |
main 2463257 |
entry removed |
entry removed |
The other commits between v4.0.0 and d5e1815 (#230, #232, #233) don't touch rollback.
Suspect code
crates/socket-patch-cli/src/commands/rollback.rs:1574-1597: succeeded_purls takes every r.success result, including results where every file is already_original. A purl in it is removable whatever scope its copies were patched in.
crates/socket-patch-cli/src/commands/rollback.rs:1641-1650: the GC pin covers removed not_installed purls only, so the before-blobs of the dropped entry are swept.
- The manifest records no scope or install path for an entry, so rollback can't tell a copy that was never patched in this scope from a reverted one.
Related
[agent] Found by the scheduled Poetry bug-hunt routine (ledger #311).
Summary
The agent-mode manifest (
.socket/manifest.json) doesn't record which scope a patch was applied in. It could be the project (a Poetry venv) or a global install (-g/--global-prefix).rollbackonly crawls its own scope. When the copy it finds there is already original, it counts the patch as rolled back, removes the manifest entry and garbage-collects its blobs. The copy that is actually patched, in the other scope, is left patched, and the run exits 0 withstatus: success.It happens in both directions, from inside a Poetry project:
scan --mode agent(patches the project's.venv)rollback -grolledBack: 0,alreadyOriginal: 1(the global copy),manifest.removedEntries: ["pkg:pypi/six@1.16.0"], exit 0. The.venvstays patched.scan -g --mode agent(patches~/.local/lib/python3.11/site-packages)rollbackrolledBack: 0,alreadyOriginal: 1(the.venvcopy),removedEntries: ["pkg:pypi/six@1.16.0"],gc.removedBlobs: 1, exit 0. The global copy stays patched.Once the entry is gone, the right-scope rollback has nothing to do. In the second case, a later
rollback -greportsrolledBack: 0, alreadyOriginal: 0and exits 0, with the global copy still patched.Impact
six,requestsorurllib3), and-gfrom inside a project directory shares the project's.socket/manifest.json.pip install --userglobal copy.Repro (Linux, Poetry 2.1.1, Python 3.11)
The mock patch API serves one agent patch for
pkg:pypi/six@1.16.0: it appends# SOCKET-PATCHEDtosix.py, using the same routes astests/vex_pypi_real_common/mod.rsplus/v0/orgs/<org>/patches/blob/<hash>.$A=--api-url http://127.0.0.1:18766 --api-token fake --org test-org --patch-server-url http://127.0.0.1:18766.Expected vs actual
--globalmeans "Operate on globally-installed packages" (CLI_CONTRACT.md, Global arguments), sorollback -gshould affect global copies and the records that belong to them, and a projectrollbackshould affect the project. The contract treats a copy that is already original / not installed as satisfying rollback's end state, because "rollback's job is 'make the tree unpatched'" (CLI_CONTRACT.md, JSON migration notes forrollback). Here the tree is not unpatched: the record's patched copy just sits outside the scope that was crawled. The entry should be kept (and its blobs pinned, as the crawler-miss guard already does fornot_installed) unless every copy it was applied to is verified original. Alternatively, rollback could warn and exit non-zero.already_originalcopy in the current scope counts assuccess. The purl lands insucceeded_purlsand is removed from the manifest, and GC sweeps the before-blobs (the crawler-miss pin covers onlynot_installed).OS × version matrix
246325792c71admacOS and Windows weren't run: this routine can't create probe branches this run. The logic has no OS-specific path.
First bad commit
d5e1815(#231, "full-state rollback default"). Both directions, 2/2 runs each, on Linux with Poetry 2.1.1:rollback -g-gapply +rollbackv4.0.0)d5e1815(#231).venvstill patched2463257The other commits between
v4.0.0andd5e1815(#230, #232, #233) don't touch rollback.Suspect code
crates/socket-patch-cli/src/commands/rollback.rs:1574-1597:succeeded_purlstakes everyr.successresult, including results where every file isalready_original. A purl in it isremovablewhatever scope its copies were patched in.crates/socket-patch-cli/src/commands/rollback.rs:1641-1650: the GC pin covers removednot_installedpurls only, so the before-blobs of the dropped entry are swept.Related
rollback -gandremove <purl> -galso unwind the current project's hosted pins and vendored wiring; on vlt they delete node_modules/left-pad too #445 / PR Fix -g touching the cwd project's state (#436, #445) #446:rollback -gunwinds the project's hosted and vendored state. That's a different leg. Fix -g touching the cwd project's state (#436, #445) #446 doesn't change the agent manifest removal, and this still reproduces on its head.rollback -gdrops the record while~/.m2stays patched. The trigger there is a discovery bug (not_installed). Here discovery is correct and the trigger is thealready_originalcopy in the other scope.