Skip to content

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

Open

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.txt consumer (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 --check goes 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 --check

vendor (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.py is the upstream PyPI file (unpatched).
  • pip install -r pylock.toml: six.py is the patched file (SAFE = True).

Expected vs actual

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.txt is pushed as a loser of the PythonLocks flavor. The retain their existing sources clause 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 in scan --mode vendored: the restore covers every hosted file (restore_upstream over 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

  1. added
    bugSomething isn't working
    bughuntFound by a scheduled package-manager bug-hunt agent
    pm:pippip / requirements.txt
    on Oct 28, 2026
  2. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged: priority:p1 (pip / pylock). Not a duplicate: #612 and #1368 fixed the same gap for a requirements.txt exported beside Pipfile.lock / uv.lock (wired with the lock via sibling_pin_wirable), but the PythonLocks flavor in detect_pypi_flavor (crates/socket-patch-core/src/vendor/pypi.rs) still lists requirements.txt as a loser, so the takeover/eject restore un-hosts it and nothing re-wires it. No open PR covers it.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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

  4. mikolalysenko commented on Oct 28, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1759


    Generated by Claude Code

  5. added 3 commits that reference this issue on Oct 28, 2026
    701b87b
    46e40d2
    09523b8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions