Skip to content

Scan from a hatchling member of a uv workspace rewrites the member's pyproject.toml as a lockless Hatch project, so the root uv.lock goes stale, uv sync --frozen installs the unpatched release and vendored vex attests not_affected #1138

Description

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

Summary

In a uv workspace, uv.lock lives at the workspace root and governs every member. When socket-patch scan runs with --cwd (or the shell) in a member directory whose pyproject.toml uses the hatchling build backend, the run never sees the ancestor uv.lock. detect_pypi_flavor falls through to step 8, "hatchling build backend → hatch", and both modes rewrite the member as a lockless Hatch project:

  • vendored writes six @ {root:uri}/.socket/vendor/pypi/<uuid>/six-1.16.0-…whl#sha256=… into the member's dependencies, puts the wheel under packages/a/.socket/, and adds [tool.hatch.metadata] allow-direct-references = true. It exits 0 success.
  • hosted writes six @ http://<patch-server>/…/six-1.16.0-…whl#sha256=… into the same place and exits 0 success.

The root uv.lock is never touched, so it no longer matches the workspace:

  • uv lock --check and uv sync --locked fail at the root.
  • uv sync --frozen, which is what CI and Docker builds use, installs the unpatched six from PyPI.
  • The vendored run's vex, from the member, attests not_affected, although the frozen install has unpatched bytes.
  • A plain uv sync relocks, and vendored mode then writes an absolute, machine-specific path (source = { path = "/home/<user>/…/packages/a/.socket/vendor/…whl" }) into the committed uv.lock. That lock breaks on every other checkout.

This is a realistic layout. uv init --package scaffolds hatchling members on uv < 0.8 (verified on 0.5.31; 0.12.23 scaffolds uv_build), so every workspace created with those uv versions has hatchling members. It is the uv-workspace counterpart of the governing-root refusals for npm / yarn / Bun (#884), pnpm (#590), vlt (#942) and cargo (#417), and the uv-side sibling of #505 (Hatch's own workspaces).

Repro (Linux, uv 0.12.23 or 0.5.31)

mkdir -p ws/packages/a/src/a && cd ws && touch packages/a/src/a/__init__.py
cat > pyproject.toml <<'EOF'
[project]
name = "root"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["a"]
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv.sources]
a = { workspace = true }
EOF
cat > packages/a/pyproject.toml <<'EOF'
[project]
name = "a"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
EOF
uv lock && uv sync
(cd packages/a && socket-patch scan --mode vendored --yes --json)   # or --mode hosted; exit 0, "success"
git diff packages/a/pyproject.toml                                   # six @ {root:uri}/.socket/vendor/... (or the hosted URL)
uv lock --check; echo $?                                             # 1: the lockfile needs to be updated
rm -rf .venv && uv sync --frozen && .venv/bin/python -c 'import six; print(getattr(six, "SOCKET_PATCHED", 0))'   # 0: unpatched
(cd packages/a && socket-patch vex --product pkg:pypi/root@0.1.0 --output vex.json)  # vendored: six not_affected

I served patch data from a local mock of the patch API (six@1.16.0, which appends a marker to six.py), the same mock as earlier uv issues.

Expected vs actual

  • Expected: fail closed and name the directory to run from, as the governing-root pre-check does for every other workspace family ("a run whose --cwd is a workspace member … reads the member's directory only, while the package manager installs from a lock in an ancestor directory", hosted/governing_root.rs module doc). Running from the workspace root is already refused, by design, with pypi_uv_workspace_unsupported / redirect_uv_project_unsupported. The member path should not quietly succeed where the root path refuses. A wired package also must not be attested not_affected while a frozen install of the project's lock installs the unpatched release.
  • Actual: exit 0 success in both modes, a stale root uv.lock, unpatched frozen installs, and a not_affected VEX (vendored).

Matrix (Linux; real uv lock / sync)

uv member build backend mode scan root uv lock --check uv sync --frozen vex from member
0.5.31 hatchling vendored exit 0, member rewritten fails unpatched not_affected
0.5.31 hatchling hosted exit 0, member rewritten fails unpatched exit 1
0.12.23 hatchling vendored (×3, with and without .venv) exit 0, member rewritten fails unpatched not_affected
0.12.23 hatchling hosted (×3) exit 0, member rewritten fails unpatched exit 1
0.12.23 uv_build vendored / hosted pypi_pyproject_only / exit 0 with nothing written ok unpatched (nothing claimed) no attestation
0.12.23 setuptools vendored / hosted pypi_pyproject_only / exit 0 with nothing written ok unpatched (nothing claimed) no attestation
0.12.23 no [build-system] vendored / hosted pypi_pyproject_only / exit 0 with nothing written ok unpatched (nothing claimed) no attestation

rollback from the member restores the member's pyproject.toml byte-identically in both modes, so the damage is reversible once someone notices it.

Not bisected. The hatch routing predates the last release, and the ledger's Sept 30 note ("scan from a member dir falls through, 0 patches") was taken with a member that had no hatchling backend. No probe branch: this is path logic with no OS-specific branch.

Suspect code

  • crates/socket-patch-core/src/vendor/pypi.rs:269 (detect_pypi_flavor) only looks at project_root. Its step 8 (return Ok((PypiFlavor::Hatch, …)), line 428) claims a hatchling member without checking for an ancestor pyproject.toml whose [tool.uv.workspace] members cover this directory, or an ancestor uv.lock.
  • crates/socket-patch-core/src/hosted/governing_root.rs:72 (refusal) has cargo and npm-family arms but no PyPI / uv-workspace arm, so the hosted Hatch rewrite isn't pre-empted either.

Activity

  1. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged: priority:p1 (uv). No duplicate or open PR. The governing-root pre-check (hosted/governing_root.rs) has no uv-workspace case for a member whose own pyproject.toml has no uv.lock beside it. detect_pypi_flavor then falls through to the hatchling → hatch branch. This is the uv counterpart of #884, #590, #942 and #417. Related, but not the same fix: #505 (Hatch's own workspaces).


    Generated by Claude Code

  2. mikolalysenko commented on Oct 8, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] More from the uv bug-hunt routine (ledger #310), on main 4657813, Linux.

    The trigger is any Hatch config in the member, not only the hatchling build backend. A fix keyed only on step 8 of detect_pypi_flavor (the hatchling backend) would miss these. Every member below sits in a uv workspace (members = ["packages/*"], root uv.lock), and the scan runs from packages/a:

    member packages/a uv vendored scan hosted scan uv sync --frozen from root vendored vex
    no build-system + hatch.toml ([envs.default]) 0.5.31, 0.12.23 exit 0, member rewritten (six @ {root:uri}/…, [tool.hatch.metadata]) exit 0, member rewritten (hosted URL) unpatched not_affected
    no build-system + [tool.hatch.envs.default] in pyproject 0.12.23 same same unpatched –
    build-backend = "uv_build" + hatch.toml 0.12.23 same same, and it adds [tool.hatch.metadata] allow-direct-references = true to a uv_build project unpatched not_affected

    Control (already in the issue): the same member with no hatch.toml and no [tool.hatch] table gives pypi_pyproject_only (vendored), and hosted writes nothing.

    So the governing-root check needs to run before any Hatch detection (hatchling backend, hatch.toml or a [tool.hatch] table), not only before the hatchling branch.

    Also checked: a non-member nested project inside the workspace (tools/x, outside the members glob, with its own uv.lock) is wired normally in both modes, and --locked installs the patched wheel. A fix shouldn't refuse that shape.


    Generated by Claude Code

  3. added
    v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.
    compatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.
    on Oct 9, 2026
  4. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    v5 release blocker (P1). A standard uv workspace member using hatchling must resolve to its governing uv.lock, not be rewritten as an unrelated lockless Hatch project.

    This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.

  5. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Claiming for v5 blocker burn-down (shared root cause: PyPI flavor detection ignores an ancestor uv workspace / uv.lock that governs the member). Branch: agent/v5-uv-workspace-member. Claim-ID: 20261009T164150Z-9798c2

  6. added 2 commits that reference this issue on Oct 9, 2026
    56d1f6d
    d0a87ca
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

    agent:claimedagent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentcompatibilityPublic CLI/JSON, saved state, upgrades, or package-manager compatibility.pm:uvuvpriority:p1v5-blockerMust resolve before v5: public interface/migration or ordinary patch-install-undo failure.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions