Repository navigation
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] requirementselement 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.lockrecord goes throughrestore_document(&live, original, new), which returnsoriginalwheneverlive == new(pypi_lock.rs:702), with no re-derivation of the[manifest] requirementsspecifier 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
mikolalysenko commentedon Oct 5, 2026
[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/rollbackexits 0 with"status": "success", thes.py.locksix element isspecifier = "==1.16.0", anduv lock --script s.py --lockedexits 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
uv: run 20 ledger (#869 filed; #411/#564 re-checked)
mikolalysenko commentedon Oct 5, 2026
[agent] Triaged as
priority:p1(uv). Still present on main0d302dc:revert_python_locks(vendor/pypi_lock.rs:742) restores the.py.lockrecord throughrestore_document(:697), which returns the recorded original whenever the live lock equals the recordednew. Nothing in that path re-derives the[manifest] requirementsspecifier the wayrespell_originaldoes 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
mikolalysenko commentedon Oct 12, 2026
[agent] Re-triage on main
7236190: this still reproduces, now also on uv 0.13.0 (released 2026-10-09) as well as 0.12.24.vendor --revert,remove pkg:pypi/six@1.16.0androllback pkg:pypi/six@1.16.0each exit 0 with"status": "success"afteruv add --script s.py "six>=1.15". Each one writes{ name = "six", specifier = "==1.16.0" }back intos.py.lock, anduv lock --script s.py --checkthen exits 1 (6/6 cells).Generated by Claude Code
mikolalysenko commentedon Oct 13, 2026
[agent] Claiming this issue (shared root cause: the PEP 723 script-lock revert restores the recorded
[manifest] requirementselement verbatim instead of re-deriving the specifier from the current script block). Branch: agent/fix-uv-script-lock-revert-specifier. Claim-ID: 2026-10-13T00:20:36Z-270554Generated by Claude Code
mikolalysenko commentedon Oct 13, 2026
[agent] Draft PR: #1458
Generated by Claude Code
Restore the script's specifier on uv script revert
Test uv script revert after a specifier edit
Keep unchanged scripts byte-exact on revert
mikolalysenko commentedon Oct 13, 2026
[agent] #1458 is ready for review. The revert now restores the specifier the script's PEP 723 block declares at revert time, using the same rule as the uv.lock lane (#841). A multi-clause range is kept as
vendor_lock_entry_driftedwith both files untouched. CI is green on8865851, and the new real-uv e2e test fails on main and passes with the fix.Generated by Claude Code
mikolalysenko commentedon Oct 13, 2026
[agent] Verified PR #1458 (head
8865851) against real uv on Linux. On main15c2c83this still fails: afteruv add --script s.py "six>=1.15",vendor --revertexits 0, writesspecifier = "==1.16.0"back intos.py.lock, anduv lock --script s.py --checkexits 1.On the PR branch, all 36 cells (uv 0.5.31 / 0.12.24 / 0.13.0 ×
six>=1.15/six~=1.16/six>=1.16,<2/ baresix×vendor --revert/remove/rollback) end withuv lock --script s.py --checkexit 0. The single-clause respellings come back as the user's specifier (>=1.15,~=1.16). The multi-clausesix>=1.16,<2keeps the vendored wiring:remove/rollbackexit 1 andvendor --revertexits 0. That matches the documented drift-keep for the project lane.Generated by Claude Code
chore(release): 5.0.1 (#1485)