Repository navigation
Hosted → vendored over requirements.txt + a pip lock pylock.toml un-hosts both files but wires only pylock.toml, so pip install -r requirements.txt goes from patched to unpatched while vendor exits 0 #1758
Description
[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).
Summary
Take a pip project that installs from both requirements.txt and a pip lock pylock.toml. scan --mode hosted pins the patched wheel in both files. A later hosted → vendored switch, either plain socket-patch vendor (the #1648 eject) or scan --mode vendored, restores both files to their upstream registry entry, but then wires the vendored wheel into pylock.toml only. requirements.txt ends up back at six==1.16.0 from PyPI. Before the switch, that install path got the patched wheel; after it, it gets the unpatched release. The run prints Vendored 1 package. and exits 0 (status: success). The only signal is a pypi_multiple_lockfiles warning that says installs driven by requirements.txt retain their existing sources. That's false here: the run itself just replaced that source.
The same project's own vendor --check, run straight afterwards, fails (exit 1) with wiring contested: pylock.toml wires .socket/vendor/pypi/<uuid>, but requirements.txt resolves the same version from elsewhere (not a Socket patch), so an install driven by requirements.txt gets the unpatched package.
Impact
- A hosted → vendored migration silently drops the patch from every
pip install -r requirements.txtconsumer (CI, Dockerfiles, deploy scripts), while the CLI reports success. Real pip 26.2.2 confirms it below. - The run breaks the "a takeover the vendored backend does not carry through keeps the hosted pin" guarantee for one of the two files it un-hosts.
vendor --checkgoes from nothing to check to failed on the same commit, with a remedy ("delete whichever of the two locks") the user didn't need before.
Repro
This uses the repo's own fixtures: a wiremock hosted API as in tests/mode_migration_pypi.rs::mount_hosted_api, plus prebuilt_common::mount_view with a PyPI JSON mock for the eject, as in tests/vendor_eject_fresh_checkout.rs::pypi_eject_with_virtualenv. requirements.txt and pylock.toml are real files: the second was made by pip lock -r requirements.txt -o pylock.toml (pip 26.2.2). There's no virtualenv (lock-only).
printf 'idna==3.7\nsix==1.16.0\n' > requirements.txt
pip lock -r requirements.txt -o pylock.toml # pip 26.2.2
socket-patch scan --mode hosted --yes # mock API grants six@1.16.0
# requirements.txt: six @ https://patch.socket.dev/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl#sha256=…
# pylock.toml: archive = { url = "https://patch.socket.dev/patch/pypi/six/…whl", hashes = { sha256 = "…" } }
socket-patch vendor # or: socket-patch scan --mode vendored --yes
socket-patch vendor --checkvendor (exit 0):
Ejecting 1 hosted package into .socket/vendor/...
Vendored 1 package.
Warning: wiring pylock.toml; installs driven by requirements.txt retain their existing sources
After it, requirements.txt reads idna==3.7\nsix==1.16.0\n (hosted pin gone, nothing vendored), and pylock.toml has archive = { path = ".socket/vendor/pypi/<uuid>/six-1.16.0-py3-none-any.whl", … }.
scan --mode vendored --yes gives the same files and prints Note: pkg:pypi/six@1.16.0 was hosted; restored its upstream registry entry (pylock.toml, requirements.txt) before vendoring (mode takeover), followed by the same warning.
vendor --check (exit 1):
pkg:pypi/six@1.16.0: wiring contested: pylock.toml wires .socket/vendor/pypi/2b1f6c1e-…, but requirements.txt resolves the same version from elsewhere (not a Socket patch), so an install driven by requirements.txt gets the unpatched package; …
Real pip 26.2.2 / CPython 3.11, each in a fresh venv:
pip install -r requirements.txt:six.pyis the upstream PyPI file (unpatched).pip install -r pylock.toml:six.pyis the patched file (SAFE = True).
Expected vs actual
- Expected (CLI_CONTRACT.md, "Takeover reconciliation"): "A takeover the vendored backend does not carry through keeps the hosted pin (pnpm hosted-to-vendored scan/get dry-run previews success for a refused takeover #853, uv scan/get dry-run does not predict a refused hosted-to-vendored takeover #944) … the restore is rolled back before anything reaches disk … so the package stays hosted-patched instead of being un-hosted and then refused."
requirements.txtshould either be wired to the vendored wheel with the lock, the way an exportedrequirements.txtbesidePipfile.lock/uv.lockis (Vendored mode in a Pipenv project wires only Pipfile.lock and silently leaves a sibling requirements.txt unpatched, and the hosted → vendored takeover reverts that file's hosted pin to plain PyPI #612, Vendored mode leaves a Pipenv-written pylock.toml or a uv-export requirements.txt unpatched beside the wired lock #1368,pypi_requirements_sibling_wired), or keep its hosted pin, with the run reporting that it wasn't converted. Thepypi_multiple_lockfilestext shouldn't claim the file keeps its existing source when the run rewrote it. - Actual: both files are un-hosted, only
pylock.tomlis wired, exit 0 /success, and the run's ownvendor --checkfails right away.
Matrix (Linux; OS-independent code path)
| requirements.txt before hosting | pylock.toml | path | result |
|---|---|---|---|
idna==3.7 + six==1.16.0 (hosted → six @ …#sha256=) |
pip lock output, 2 packages |
plain vendor (eject) |
fail (reproduced 3/3) |
| same | same | scan --mode vendored --yes |
fail |
pip-compile style, hash continuations (hosted → six @ … --hash=) |
same | plain vendor |
fail |
| requirements.txt only (no pylock) | none | plain vendor |
pass (wired) |
| pylock.toml only | pip lock output |
plain vendor |
pass |
Bisect: not a #1648 regression. The plain-vendor eject at #1648's parent 8244b94 gives the same result (both files un-hosted, only pylock.toml wired, vendor --check contested), and so does the scan --mode vendored takeover on main b787454. #1648 does make plain vendor the default way to leave hosted mode, even when .socket/manifest.json exists, so more projects now go through this path.
Suspect code
crates/socket-patch-core/src/vendor/pypi.rs:389-433(detect_pypi_flavor): when a pylock holds the target,requirements.txtis pushed as a loser of thePythonLocksflavor. Theretain their existing sourcesclause assumes the file wasn't touched, but in a takeover or eject the upstream restore has already rewritten it.crates/socket-patch-cli/src/commands/vendor.rs:1716(run_eject) and the takeover inscan --mode vendored: the restore covers every hosted file (restore_upstreamover all pins), but nothing checks that the vendored backend re-wires each restored file. A file the backend leaves as a loser should keep its hosted pin, or the purl should be refused.
No probe runs: the code path is OS-independent and was reproduced on Linux only (probe branches are still blocked for this routine).
Activity
pip bug-hunt: run 47 ledger (#1758, #668 closed)
mikolalysenko commentedon Oct 28, 2026
[agent] Triaged:
priority:p1(pip / pylock). Not a duplicate: #612 and #1368 fixed the same gap for arequirements.txtexported besidePipfile.lock/uv.lock(wired with the lock viasibling_pin_wirable), but thePythonLocksflavor indetect_pypi_flavor(crates/socket-patch-core/src/vendor/pypi.rs) still listsrequirements.txtas a loser, so the takeover/eject restore un-hosts it and nothing re-wires it. No open PR covers it.Generated by Claude Code
mikolalysenko commentedon Oct 28, 2026
[agent] Claiming this issue (shared root cause: the PythonLocks vendored flavor treats a requirements.txt beside a pylock as a loser instead of wiring its pin with the lock, so a hosted→vendored restore un-hosts it). Branch: agent/fix-pylock-requirements-sibling. Claim-ID: 2026-10-28T02:21:14Z-452f40
Generated by Claude Code
mikolalysenko commentedon Oct 28, 2026
[agent] Draft PR: #1759
Generated by Claude Code
Wire requirements.txt beside a pylock too
Tidy pylock sibling wiring after review
Restore only rewritten pylock files on refusal