[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When a uv project declares dynamic = ["dependencies"] (the dependency list comes from the build backend, here setuptools reading requirements.in), scan --mode hosted wires the patch and reports success. It writes [tool.uv] override-dependencies plus a [tool.uv.sources] url, and repoints the lock's requires-dist entry and [manifest] overrides. Real uv installs the patched wheel from that.
Every unwind of that wiring then fails with:
cannot restore pkg:pypi/six@1.16.0 to its upstream registry entry: uv.lock: pyproject.toml no longer declares six, so the lock entry's specifier is not derivable; restore it from version control instead (`git checkout -- uv.lock`)
rollback gives exit 1 / partial_failure, remove gives hosted_revert_failed, and a vendored takeover (scan --mode vendored) gives cannot vendor over the live hosted pin: …. The pyproject.toml and uv.lock stay hosted. The pyproject never declared six statically, so the hosted run created a one-way state its own unwind can't read.
For contrast, vendored mode refuses this project shape up front (pypi_uv_dynamic_dependencies, crates/socket-patch-core/src/vendor/pypi_uv.rs:211) before writing anything. Hosted mode has no equivalent guard.
Impact
A user who hosts a patch on a setuptools/hatch project with dynamic dependencies can't roll back, remove the patch or switch to vendored mode without git checkout. This shape is common: dynamic = ["dependencies"] with requirements.in / requirements.txt files is a standard setuptools pattern. The documented refusal list in CLI_CONTRACT.md "Hosted unwind coverage" doesn't mention dynamic dependencies.
Repro (Linux, uv 0.8.17; a local mock patch API serves the patched six wheel)
cat > pyproject.toml <<'P'
[project]
name = "app"
version = "1.0.0"
requires-python = ">=3.8"
dynamic = ["dependencies"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
dependencies = { file = ["requirements.in"] }
[tool.setuptools]
py-modules = []
P
printf 'six==1.16.0\nidna==3.7\n' > requirements.in
uv lock && uv sync
socket-patch scan --mode hosted --yes --json --api-url $MOCK --api-token t --org test-org
# -> redirected 1, rewrittenFiles [pyproject.toml, uv.lock]
# pyproject gains: [tool.uv] override-dependencies = ["six==1.16.0"] + [tool.uv.sources] six = { url = … }
uv sync --locked # installs the hosted wheel; `import six; six.SOCKET_PATCHED` == 1
socket-patch rollback --yes --json --api-url $MOCK --api-token t --org test-org --patch-server-url $MOCK
# -> exit 1, hosted.failed[0].error = "… pyproject.toml no longer declares six, so the lock entry's specifier is not derivable …"
socket-patch remove pkg:pypi/six@1.16.0 --yes --json … # -> hosted_revert_failed, same message
socket-patch scan --mode vendored --yes --json … # -> "cannot vendor over the live hosted pin: …", same cause
(SOCKET_PYPI_JSON_API points at a pypi.org pass-through so the upstream restore can fetch hashes. The lock diff is the normal hosted rewrite: requires-dist { name = "six", specifier = "==1.16.0" } → { name = "six", url = "…" }, plus [manifest] overrides.)
Expected vs actual
- Expected: CLI_CONTRACT.md ("Hosted unwind coverage", pypi bullet) says rollback / remove / takeover restore each hosted pin from PyPI's JSON API, and that "a transitive
override-dependencies entry hosted mode added is removed". Dynamic dependencies aren't in the refusal list. So either the unwind restores the lock (the pre-scan requires-dist specifier is the one the build backend produced; hosted mode itself treated six as an override-wired dependency), or hosted scan refuses / warns up front the way vendored does with pypi_uv_dynamic_dependencies, instead of writing wiring nothing can undo.
- Actual: the scan succeeds (exit 0) and every unwind refuses. Repeated runs give the same result.
Matrix (Linux; OS-independent TOML logic)
| uv |
hosted scan |
uv sync --locked after scan |
rollback |
remove |
vendored takeover |
| 0.4.30 |
redirected |
fails (documented <0.5.5 override boundary) |
refused |
– |
– |
| 0.5.31 |
redirected |
patched |
refused |
– |
– |
| 0.8.17 |
redirected |
patched |
refused (×2) |
refused |
refused |
| 0.12.22 |
redirected |
patched |
refused |
– |
– |
Also checked on draft PR #625 (48bd239, the #606/#473 fix): that build rolls #606 and #473 back byte-identically, but this dynamic case still refuses with the same message, so #625 doesn't cover it.
First bad
v5 upstream restore (no hosted ledger). Not bisected further: main 045d7ec and released 4.0.0 predate it.
Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1053: declared_clauses(meta, declared, &hit.name)?.ok_or_else(|| "… no longer declares …"). For a dynamic project, the pyproject has no static declaration, and the hosted-added override-dependencies entry is only used to drop the [manifest] overrides entry (:1045), not to restore requires-dist.
- The hosted writer that classifies the dynamic dependency as override-wired (
crates/socket-patch-core/src/utils/python_lock.rs metadata completion) has no dynamic check, unlike vendor/pypi_uv.rs:211.
No probe runs: the logic is OS-independent.
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
When a uv project declares
dynamic = ["dependencies"](the dependency list comes from the build backend, here setuptools readingrequirements.in),scan --mode hostedwires the patch and reports success. It writes[tool.uv] override-dependenciesplus a[tool.uv.sources]url, and repoints the lock'srequires-distentry and[manifest] overrides. Real uv installs the patched wheel from that.Every unwind of that wiring then fails with:
rollbackgives exit 1 /partial_failure,removegiveshosted_revert_failed, and a vendored takeover (scan --mode vendored) givescannot vendor over the live hosted pin: …. The pyproject.toml and uv.lock stay hosted. The pyproject never declaredsixstatically, so the hosted run created a one-way state its own unwind can't read.For contrast, vendored mode refuses this project shape up front (
pypi_uv_dynamic_dependencies,crates/socket-patch-core/src/vendor/pypi_uv.rs:211) before writing anything. Hosted mode has no equivalent guard.Impact
A user who hosts a patch on a setuptools/hatch project with dynamic dependencies can't roll back, remove the patch or switch to vendored mode without
git checkout. This shape is common:dynamic = ["dependencies"]withrequirements.in/requirements.txtfiles is a standard setuptools pattern. The documented refusal list in CLI_CONTRACT.md "Hosted unwind coverage" doesn't mention dynamic dependencies.Repro (Linux, uv 0.8.17; a local mock patch API serves the patched six wheel)
(
SOCKET_PYPI_JSON_APIpoints at a pypi.org pass-through so the upstream restore can fetch hashes. The lock diff is the normal hosted rewrite:requires-dist{ name = "six", specifier = "==1.16.0" }→{ name = "six", url = "…" }, plus[manifest] overrides.)Expected vs actual
override-dependenciesentry hosted mode added is removed". Dynamic dependencies aren't in the refusal list. So either the unwind restores the lock (the pre-scanrequires-distspecifier is the one the build backend produced; hosted mode itself treated six as an override-wired dependency), or hosted scan refuses / warns up front the way vendored does withpypi_uv_dynamic_dependencies, instead of writing wiring nothing can undo.Matrix (Linux; OS-independent TOML logic)
uv sync --lockedafter scanAlso checked on draft PR #625 (
48bd239, the #606/#473 fix): that build rolls #606 and #473 back byte-identically, but this dynamic case still refuses with the same message, so #625 doesn't cover it.First bad
v5 upstream restore (no hosted ledger). Not bisected further: main
045d7ecand released 4.0.0 predate it.Suspect code
crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1053:declared_clauses(meta, declared, &hit.name)?.ok_or_else(|| "… no longer declares …"). For a dynamic project, the pyproject has no static declaration, and the hosted-addedoverride-dependenciesentry is only used to drop the[manifest] overridesentry (:1045), not to restorerequires-dist.crates/socket-patch-core/src/utils/python_lock.rsmetadata completion) has nodynamiccheck, unlikevendor/pypi_uv.rs:211.No probe runs: the logic is OS-independent.