Skip to content

uv vendored → hosted takeover strands a package that vendored mode pinned to a different version than uv.lock: the wet run reverts to the unpatched release (exit 1), while --dry-run previews a clean takeover #723

Closed

Description

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

Summary

On a uv project, vendored mode pins the patched package to the patch's version even when uv.lock resolved another one (pypi_uv.rs does this on purpose: the unit test override_wiring_matches_fixture_byte_identically covers "the lock's 1.17.0 → 1.16.0 version pin-down"). For example, python-dateutil pulls in six 1.17.0, the manifest patch is for six@1.16.0, and vendor writes override-dependencies = ["six==1.16.0"] plus a path source, and rewrites the lock entry to version = "1.16.0". A direct six>=1.15 gets the same treatment, with no warning.

The vendored → hosted takeover (scan --mode hosted over that project, from #503) doesn't account for this:

  • Wet run: it reverts the vendored wiring first. That deletes the committed wheel and the ledger entry, and restores six 1.17.0 in uv.lock. Only then does it ask the uv rewriter to pin six@1.16.0, which no longer has a lock entry: redirect_uv_entry_not_found, redirected: 0, partial_failure, redirect_takeover_unpatched, exit 1. A project that installed patched six before the command now installs unpatched six 1.17.0 from PyPI.
  • --dry-run: reports status: success, redirected: 1 and only redirect_would_revert_vendored. Every previewed takeover is counted as confirmed (hosted.rs:1018, confirmed.extend(dry_run_takeover)), so the strand is never predicted.

This is the uv counterpart of #699 (requirements.txt). The cause is different: the lock entry the hosted rewriter needs disappears with the revert. Open PR #708 adds a takeover preflight only for requirements wiring (preflight_requirements_takeover), and I verified this case still strands on its head 5f291a5.

Impact

  • A user who previews with --dry-run, sees a clean takeover, and runs it loses the patch. uv sync --locked succeeds and installs the vulnerable upstream release, with the vendored wheel already deleted.
  • It's reachable whenever vendored mode was run with a manifest entry whose version differs from the lock's. For example, a committed .socket/manifest.json from agent mode or get records six@1.16.0, and uv lock --upgrade later moved the transitive six to 1.17.0. Hosted discovery then finds six@1.16.0 in the vendored lock (Socket's own references stay discoverable), so the API offers the 1.16.0 patch and the takeover runs.
  • The printed remedy ("re-run scan --mode hosted") can't work: hosted mode never pins a version the lock doesn't resolve. Only scan --mode vendored recovers.

Repro

I used the mode_migration_pypi.rs harness: stage_manifest (six@1.16.0), the prebuilt fixture server for vendor, and mount_hosted_api for the hosted scan. Real uv was used for lock and install.

mkdir proj && cd proj
cat > pyproject.toml <<'EOF'
[project]
name = "demo"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["python-dateutil==2.9.0.post0"]
EOF
uv lock                                  # six 1.17.0 (transitive)
# stage .socket/manifest.json for pkg:pypi/six@1.16.0, then:
socket-patch vendor --json               # success, no warning; uv.lock six -> version "1.16.0", path source;
                                         # pyproject gains override-dependencies = ["six==1.16.0"] + [tool.uv.sources]
uv sync --locked && .venv/bin/python -c 'import six; print(six.SOCKET_PATCHED)'   # 1
socket-patch scan --mode hosted --yes --dry-run --json --api-url $API --org test-org --api-token x --patch-server-url $API
#   status: success, redirect.redirected: 1, warnings: [redirect_would_revert_vendored]   exit 0
socket-patch scan --mode hosted --yes --json --api-url $API --org test-org --api-token x --patch-server-url $API ; echo $?
#   status: partial_failure, redirected: 0,
#   warnings: redirect_takeover_reverted_vendored, redirect_uv_entry_not_found, redirect_takeover_unpatched ; exit 1
git status --short                        # .socket/vendor/** deleted, pyproject.toml + uv.lock back to six 1.17.0 from PyPI
rm -rf .venv && uv sync --locked && .venv/bin/python -c 'import six; print(getattr(six,"SOCKET_PATCHED","UNPATCHED"))'   # UNPATCHED

The direct variant dependencies = ["six>=1.15"] (lock: 1.17.0) behaves identically.

Expected vs actual

  • Expected: CLI_CONTRACT §scan --mode hosted / the vendored_takeover doc in hosted.rs: "A takeover must leave the project FULLY hosted … A purl whose vendored state cannot be cleanly reverted … is REFUSED — skipped with an actionable error — never half-migrated". The dry-run preview must report the wet run's outcome (dry_run_predicts_drifted_takeover_refusal pins this for drifted wiring). When the reverted lock won't resolve the patch's version, the takeover should be refused before the revert, keeping the vendored patch, and the dry run should predict that refusal. A uv-side twin of the npm takeover_refusal gates at hosted.rs:1725, which today returns None for every non-npm purl, would cover it.
  • Actual: the wet run reverts first and strands the package unpatched (exit 1). The dry run previews success with redirected: 1.

Matrix (Linux, main 045d7ec)

uv transitive pin-down (dateutil → six 1.17.0) direct six>=1.15 dry run uv sync --locked after the wet run
0.5.31 strands (exit 1) – success, redirected: 1 unpatched 1.17.0
0.8.17 strands (reproduced 2×) strands success, redirected: 1 unpatched 1.17.0
0.12.23 (newest) strands – success, redirected: 1 unpatched 1.17.0
0.8.17 on PR #708 5f291a5 strands – success, redirected: 1 unpatched
control: lock already resolves 1.16.0 pass (redirected: 1, patched) patched

The decision is made on lock text, so it doesn't depend on the OS, and I ran no probe. First bad: #503 (0ac5b91a), which enabled the PyPI takeover. Before it the takeover was refused (#328), and the vendored patch survived.

Suspect code

  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1018: confirmed.extend(dry_run_takeover).
  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1725: takeover_refusal gates npm only. PyPI has no "will the hosted rewriter reach this after the revert" check, and Fix PyPI vendored→hosted takeover stranding unreachable pins (#699) #708 adds one for requirements only.
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:1150: the lock entry's version is rewritten to the patch version (the pin-down that the revert undoes).
  • crates/socket-patch-core/src/patch/redirect/mod.rs:4643: redirect_uv_entry_not_found, which the takeover runs into after reverting.

Related: #699 / PR #708 (requirements.txt shape), #568 (drift-kept ledger), #369 (the yarn berry reverse direction, closed).

Activity

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

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p1 (uv). Not a duplicate of #699: that one is the requirements.txt rewriter only reaching the root file, and open PR #708 adds a gate just for that flavor (preflight_requirements_takeover inside takeover_refusal in scan/hosted.rs). Here the cause is uv-specific. Vendored uv pins the lock entry to the patch version, so reverting it brings back the lock's own version, and the hosted uv rewriter can no longer find an entry for the patched version. The fix belongs in the same takeover_refusal gate as a uv preflight (refuse when the restored lock won't resolve the patch version), and the dry-run preview has to predict that refusal too. Kept as its own fix; it will touch the same closure as #708.


    Generated by Claude Code

  3. mikolalysenko commented on Oct 5, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Re-checked on main 9c43dfc (uv bug-hunt run 21, ledger #310). This still reproduces, the same way on both shapes and on uv 0.8.17 and 0.12.23 (4/4 cells).

    Setup: uv lock resolves six 1.17.0, either directly (six>=1.15) or transitively (python-dateutil==2.9.0.post0). Then get aaaaaaaa-…-001 --id --save-only --mode agent stages six@1.16.0 in the manifest, and vendor pins uv.lock to 1.16.0. uv sync --locked then installs patched six.

    scan --mode hosted --dry-run  -> exit 0, status success, redirected: 1
    scan --mode hosted            -> exit 1, partial_failure: redirect_takeover_reverted_vendored,
                                     redirect_uv_entry_not_found, redirect_takeover_unpatched
    rm -rf .venv && uv sync --locked -> six 1.17.0, UNPATCHED; .socket/vendor is gone
    

    None of the commits since 99f61d2 (#858, #802, #807 and the others) touch the takeover gate.


    Generated by Claude Code

  4. added a commit that references this issue on Oct 5, 2026
  5. mikolalysenko commented on Oct 6, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming this issue (with #945; shared root cause: the PyPI vendored → hosted takeover gate takeover_refusal in scan/hosted.rs only checks requirements.txt reach, never whether the uv / Poetry hosted rewriter can pin the package after the revert). Branch: agent/fix-pypi-hosted-takeover-preflight. Claim-ID: 2026-10-06T15:20:47Z-fce7c7


    Generated by Claude Code

  6. mikolalysenko commented on Oct 6, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #946


    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