Skip to content

Vendored uv script lock: after the user changes the vendored package's specifier in the PEP 723 block, vendor --revert / remove / rollback write the stale ==1.16.0 back into <script>.py.lock, so uv run --locked fails (exit 0) #869

Description

[agent] Found by the scheduled uv bug-hunt routine (ledger #310).

Summary

This is #840's shape in the PEP 723 script-lock lane, which #841 didn't cover. #841 re-derives the restored specifier only for uv.lock's requires-dist, requires-dev and [manifest] constraints (respell_original, crates/socket-patch-core/src/vendor/pypi_uv.rs:1064). Script locks are reverted by a different backend (revert_python_locks, crates/socket-patch-core/src/vendor/pypi_lock.rs:742), which still restores the recorded original.

When six is vendored for s.py, the .py.lock [manifest] requirements element becomes { name = "six", path = ".socket/vendor/…" }, without a specifier. If the user then changes six's requirement in the script (uv add --script s.py "six>=1.15", or a hand edit plus uv lock --script s.py), uv leaves s.py.lock byte-identical. On revert, the lock record therefore still equals its recorded new, and restore_document (pypi_lock.rs:702) returns the recorded original verbatim, with specifier = "==1.16.0". The script record is merged (pypi_lock.rs:776-784): the tool.uv.sources line is dropped and the user's six>=1.15 is kept. Both records restore "cleanly", so no drift is reported, and the pair ends up inconsistent.

Impact

vendor --revert, remove and rollback exit 0 with status: "success" and no warning. After that, uv run --locked --script s.py and uv lock --script s.py --locked fail with "The lockfile at uv.lock needs to be updated, but --locked was provided". A plain uv run --script silently relocks. This is the same CI breakage #840 described for projects.

Repro

This uses the bug-hunt mock patch API (serves POST /patch/package and a patched six 1.16.0 wheel with SRI sha512), the same harness as #806, #821 and #840.

mkdir sc && cd sc
cat > s.py <<'EOF'
# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "six==1.16.0",
#     "attrs>=20",
# ]
# ///
import six
print(getattr(six, "SOCKET_PATCHED", 0))
EOF
uv lock --script s.py
SP="socket-patch --api-url $MOCK --api-token fake --org acme --patch-server-url $MOCK --vendor-url $MOCK"
$SP scan --mode vendored --yes --json      # s.py.lock: { name = "six", path = ".socket/vendor/…" }
cp s.py.lock vend.lock
uv add --script s.py "six>=1.15"           # s.py now says six>=1.15
cmp vend.lock s.py.lock && echo identical  # identical
uv run --locked --script s.py              # 1 (patched), lock ok
$SP vendor --revert --yes --json           # exit 0, "status": "success", no events
grep 'name = "six", specifier' s.py.lock   # { name = "six", specifier = "==1.16.0" }   <- stale
uv run --locked --script s.py              # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.

remove pkg:pypi/six@1.16.0 and rollback pkg:pypi/six@1.16.0 behave the same, and so do a hand edit plus uv lock --script s.py and other specifiers (six==1.16.*, a bare six, six>=1.16,<1.17).

Expected vs actual

  • Expected (docs/testing/uv-compatibility.md, "Revert state retains the original wiring"): "Script and lock edits are treated as a pair: conflicting changes preserve both files and their recovery state rather than restoring only one side." After Fix uv revert restoring a stale specifier (#840) #841, the same doc also says revert restores entries "with the specifier pyproject.toml declares at revert time, not the one recorded when vendoring". The script lane should do the same from the PEP 723 block, or, if it can't derive uv's spelling, treat the edit as drift and keep both files (vendor_lock_entry_drifted), as the project lane does for multi-clause ranges.
  • Actual: the stale recorded [manifest] requirements element is written back, exit 0, status: "success", and the lock no longer matches the script.

Control (pass): the vendored → hosted takeover (scan --mode hosted) on the same edited script re-derives the requirement, and uv lock --script --locked passes (all OS × versions below). The project-lane equivalent (#840) passes on main for deps, extras and dev groups × 11 specifier spellings × uv 0.2.37 / 0.4.30 / 0.5.31 / 0.8.17 / 0.12.23.

Matrix (main 99f61d2)

"fail" = 0 vendor refs left in s.py and s.py.lock, s.py.lock six element specifier = "==1.16.0", and uv lock --script s.py --locked non-zero. Each cell is {uv add --script, hand edit + uv lock --script} × {six>=1.15, six==1.16.*} × {vendor --revert, remove, rollback} = 12 unwinds.

OS uv 0.5.31 uv 0.8.17 uv 0.12.23 V→H takeover
Linux (sandbox) fail (12/12, plus >=1.16, bare six, multi-clause) fail (12/12) fail (12/12) pass
Windows (windows-latest probe) fail (12/12) job ran, not read in detail fail (12/12) pass (4/4 per version)
macOS (macos-latest probe) pending (runners queued) pending pending –

Not bisected. The behaviour comes from the recorded-original design of the script-lock backend, and #841 fixed only the uv.lock lane.

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_lock.rs:786-787: the .py.lock record goes through restore_document(&live, original, new), which returns original whenever live == new (pypi_lock.rs:702), with no re-derivation of the [manifest] requirements specifier from the current PEP 723 block.
  • crates/socket-patch-core/src/vendor/pypi_lock.rs:776-784: the script record merges the user's edit, so the two records disagree without either reporting drift.
  • Compare the project lane's fix: respell_original / respell_lock_specifier (pypi_uv.rs:1064, redirect/upstream/uv.rs).

Probe run: https://github.com/SocketDev/socket-patch/actions/runs/37326331477 (bughunt/uv/20261005-script-spec-revert).

Related: #840 (project lane, fixed by #841), #474 (script revert after an unrelated uv add --script, fixed).


Backlog review — 2026-10-08

Priority: P1 → P2. Specifier drift in uv script locks causes an unwind problem in a specific edited layout; retain at P2.

Activity

  1. mikolalysenko commented on Oct 5, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Matrix update from the probe run (https://github.com/SocketDev/socket-patch/actions/runs/37326331477): macOS (macos-latest, arm64) × uv 0.12.23 also fails 12/12. Every vendor --revert / remove / rollback exits 0 with "status": "success", the s.py.lock six element is specifier = "==1.16.0", and uv lock --script s.py --locked exits 1. The V→H takeover passes 4/4. All 9 OS × version jobs completed; I read the logs for Windows 0.5.31 / 0.12.23 and macOS 0.12.23.


    Generated by Claude Code

  2. added a commit that references this issue on Oct 5, 2026
  3. mikolalysenko commented on Oct 5, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p1 (uv). Still present on main 0d302dc: revert_python_locks (vendor/pypi_lock.rs:742) restores the .py.lock record through restore_document (:697), which returns the recorded original whenever the live lock equals the recorded new. Nothing in that path re-derives the [manifest] requirements specifier the way respell_original does for uv.lock. This isn't a duplicate: #840 / #841 covered only the project lane. No open PR addresses it yet.


    Generated by Claude Code

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