Skip to content

Keep the hosted pin when a vendored takeover is refused (#853, #944) - #963

Open
Mikola Lysenko (mikolalysenko) wants to merge 7 commits into
mainfrom
agent/fix-takeover-vendored-preflight
Open

Mikola Lysenko (mikolalysenko) wants to merge 7 commits into
mainfrom
agent/fix-takeover-vendored-preflight

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Refs #853
Refs #944

Both issues stay open for the remaining slice: scan / get --dry-run parity (see "Not in this PR").

What users saw

Switching a hosted-patched package to vendored mode (scan --mode vendored, get --mode vendored, or vendor) restored the package's upstream registry entry first, and only then ran the vendored backend. If that backend refused the package, the package was left patched in neither mode, and the next install pulled the unpatched release. The run did exit 1, but the hosted pin was already gone. Refusals that trigger this include:

Root cause

The takeover in crates/socket-patch-cli/src/commands/vendor.rs committed restore_upstream before the target backend's refusals ran. Earlier fixes added per-ecosystem preflights in front of the restore (Bun, vlt, yarn berry, npm v1, gem), but pnpm and PyPI had none. Patching one more ecosystem at a time would leave every refusal nobody has found yet with the same data loss.

Fix

The fix sits at the shared boundary, so it covers every ecosystem and every refusal.

The vendored run already holds every lockfile, manifest and config write in memory until one group commit at the end (utils::group_commit). This PR adds two things to that:

  • GroupCommit::savepoint() / rollback_to(), which roll back the captured project files and leave the ledgers alone.
  • group_commit::captures(rel), which says whether a write to a path is captured.

The takeover takes a savepoint before the restore. If the backend then does not vendor the purl (any refusal, an unsupported result, or a failed apply that recorded nothing), the takeover rolls back to the savepoint before anything reaches disk:

  • the purl fails with the backend's own code and detail
  • the hosted wiring stays byte-for-byte
  • vendor_takeover_reverted_redirect, the restore's advisories and the vlt store heal are only emitted once the restore stands

A restore that writes an uncaptured file (.socket/gradle/hosted-index.tsv) keeps today's behaviour.

vendor --dry-run now previews the same failed <code>. It runs the backend's dry run over the restored text, staged in a throwaway overlay that is never committed. A restore with a binary lock or an uncaptured file is not previewed.

CLI_CONTRACT.md "Takeover reconciliation" documents all of this.

Not in this PR (follow-up slice)

The scan / get --mode vendored --dry-run preview (preview_vendor_json) is an offline, ledger-only classification. It doesn't model the takeover at all: it doesn't report vendor_would_revert_redirect either. So it still lists these purls as would_vendor. Fixing that means running the restore in the preview and evaluating the npm-family lock_text_refusals over the restored text, plus a new uv inline-sources gate. That's a separate change with its own contract decision, so the contract now says it explicitly.

Other changes

  • 8a796fb fixes two CI guards this branch tripped. It drops the renamed vlt test from docs/testing/vlt-coverage.json's reinstall-advisory variant (the test now asserts that advisory is not emitted), and it moves the new test files onto hermetic::binary_command() so the spawn_env_hygiene ratchet accepts them.
  • 3015804 ports Route Gradle digests through utils::digest #878 ("Route Gradle digests through utils::digest"). The production_digests_go_through_the_helpers guard test is red on main, and this commit becomes a no-op once Route Gradle digests through utils::digest #878 lands.
  • vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store pinned the old behaviour (un-host, then heal the store). It is renamed to …_keeps_the_hosted_pin and now asserts the hosted lock, package.json and store copy stay put.

Test evidence (red on main → green here)

Issue / case Test main this PR
#853 catalog (scan) scan_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin FAIL (hosted pin gone, reported restored) ok
#853 catalog (get) get_vendored_over_hosted_pnpm_catalog_dep_keeps_the_hosted_pin FAIL ok
#853 CRLF lock scan_vendored_over_hosted_pnpm_crlf_lock_keeps_the_hosted_pin FAIL ok
#853 workspace override scan_vendored_over_hosted_pnpm_workspace_override_keeps_the_hosted_pin FAIL ok
#853 vendor --dry-run parity vendor_dry_run_over_hosted_pnpm_catalog_dep_previews_the_refusal FAIL ok
control scan_vendored_over_hosted_pnpm_plain_dep_still_takes_over ok ok
#944 uv inline sources scan_vendored_over_hosted_uv_inline_sources_keeps_the_hosted_pin FAIL ok
#944 marker-split requirements scan_vendored_over_hosted_marker_split_requirements_never_unpatches FAIL ok
savepoint primitive rollback_to_savepoint_forgets_later_project_writes_only, captures_only_root_relative_commit_points n/a ok

The new files are tests/in_process_vendor_pnpm_takeover.rs and tests/in_process_vendor_pypi_takeover.rs. In the hermetic uv fixture, the run can stop at vendor_prebuilt_required before the uv backend's own inline-table refusal. That is still a post-restore refusal, so it exercises the rollback, and the test asserts the invariant (hosted pin byte-for-byte) rather than the specific code.

Other suites run locally, all passing:

  • in_process_vendor (116), in_process_vendor_bun_takeover (30), in_process_vendor_npm_v1_takeover, vendor_eject, spawn_env_hygiene, contract_gradle_codes, help_text_hygiene
  • scripts/tests (254)
  • CLI lib (847)
  • cargo clippy --workspace --all-features -- -D warnings, clean

Core lib is 5247 passed, 5 failed. All 5 fail identically on main: 4 depend on permission errors and can't fail when the sandbox runs as root, and the 5th is the digest guard, fixed here by the #878 port.

cargo test --workspace didn't fit in this sandbox's disk, and cargo fmt --check is already red on main with the pinned 1.93.1 toolchain (CI doesn't run it). Only the new code here was formatted. CI is the full run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VFYnhVEY8Sd5MjToPfcrd3


Note

Medium Risk
Changes atomic multi-file vendor takeover behavior and group-commit rollback semantics; incorrect rollback could leave inconsistent lock state, though the fix targets a known data-loss bug with broad test coverage.

Overview
Hosted → vendored takeover no longer leaves packages unpatched when vendoring fails (#853, #944). Previously, vendor / scan / get --mode vendored committed the upstream registry restore before the vendored backend ran; a later refusal (pnpm catalog: / CRLF lock / overrides, uv inline sources, etc.) dropped the hosted pin with nothing vendored in its place.

The shared path in vendor.rs now takes a GroupCommit savepoint before restore_upstream, defers vendor_takeover_reverted_redirect and related advisories until vendoring actually succeeds, and rollback_to the savepoint when the backend refuses, apply fails without a ledger entry, or redownload fails—so lockfiles and hosted wiring stay byte-for-byte. Restores that touch uncaptured paths (e.g. .socket/gradle/hosted-index.tsv) are unchanged. vendor --dry-run previews the same failed <code> by dry-running the backend over the restored project in a throwaway overlay (takeover_dry_refusal).

group_commit adds savepoint / rollback_to, captures(rel), and clone support for overlay state. CLI_CONTRACT documents rollback semantics and notes scan/get vendored dry-run still does not model takeover.

Tests: new hermetic pnpm and PyPI takeover suites; vlt test renamed to assert the hosted pin is kept. Minor digest calls route through utils::digest (aligned with #878).

Reviewed by Cursor Bugbot for commit 284d687. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
Switching a hosted-patched package to vendored mode (scan or get
--mode vendored, or vendor) first restores the package's upstream
registry entry, then runs the vendored backend. When that backend
refused the package (a pnpm catalog dependency, a CRLF pnpm lock, a
workspace exact-pin override, a uv inline sources table, ...), the
restore had already been made, so the package ended up neither
hosted nor vendored and the next install pulled the unpatched
release.

The takeover now takes a savepoint in the run's group commit before
the restore. If the backend does not vendor the package, the restore
is rolled back in memory before anything reaches disk: the hosted
pin, lock and side config stay byte-for-byte, the purl fails with the
backend's own code, and no "restored its upstream entry" advisory is
printed. This covers every ecosystem's post-restore refusal, not just
the pnpm and uv triggers that were reported. A restore that writes a
file the group commit does not capture keeps the old behaviour.

vendor --dry-run also previews the backend's refusal over the
restored project, staged in a throwaway overlay that never reaches
disk.

Refs #853, #944

Assisted-by: Claude Code:claude-opus-5-5
Adds hermetic regression tests for #944. In both cases a hosted six
pin is switched to vendored mode and refused after the takeover's
upstream restore:

- a uv project whose pyproject.toml names [tool.uv] sources as an
  inline table
- a `uv pip compile --universal` requirements.txt that splits six
  across two exact pins by marker (#928's shape)

Either way, six must stay patched: vendored, or still hosted with
pyproject.toml, uv.lock and requirements.txt byte-for-byte as hosted
mode wrote them. Both tests fail on main, where the restore was
already made when the refusal came, and pass with the rollback.

Refs #944

Assisted-by: Claude Code:claude-opus-5-5
Ports the change from #878 so the digest-helper guard test, which is
red on main, passes on this branch. It becomes a no-op once #878
lands.

Assisted-by: Claude Code:claude-opus-5-5
Adds a regression test: `vendor --dry-run` over a hosted pnpm catalog
pin now previews the backend's refusal (vendor_lock_entry_unsupported)
instead of promising the takeover, and the wet `vendor` keeps the
hosted pin byte-for-byte.

CLI_CONTRACT.md's "Takeover reconciliation" now states that a
takeover the backend refuses is rolled back with the hosted wiring
untouched, and that the scan / get --dry-run preview does not model
the takeover yet.

Refs #853, #944

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko Mikola Lysenko (mikolalysenko) changed the title Fix hosted→vendored takeover un-hosting refused packages (#853, #944) Keep the hosted pin when a vendored takeover is refused (#853, #944) Oct 6, 2026
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 6, 2026 21:23
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

docs/testing/vlt-coverage.json still listed the renamed vlt takeover
test under the reinstall-advisory variant. That test now asserts the
advisory is not emitted (the hosted pin stays), so drop it from that
list. lint-ecosystems failed on the stale name.

The two new takeover test files spawned the binary with a bare
Command::new, which the spawn_env_hygiene ratchet rejects. They now
use hermetic::binary_command().

Refs #853, #944

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread crates/socket-patch-cli/src/commands/vendor.rs
Bugbot found two paths that stopped vendoring a purl after the
upstream restore was already staged, but skipped the rollback: a
restore whose flush failed partway, and an artifact redownload that
failed. The group commit would still write the staged restore, so the
package was left un-hosted and unvendored, which is the #853 / #944
bug by another route. Both paths now roll back to the savepoint, the
same way a backend refusal does.

Refs #853, #944

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 284d687. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 6, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[burn-down agent] Ready for review at 284d687.

  • CI: 97/97 check runs green on 284d687; mergeable, not behind main.
  • Bugbot: reviewed 284d687, no new issues. Its one earlier finding (takeover rollback skipped on the redirect_revert_failed / vendor_redownload_failed early exits, Medium) was fixed in 284d687, and the thread is resolved.
  • For the reviewer: the core change is the GroupCommit::savepoint() / rollback_to() pair in utils/group_commit.rs and how vendor.rs uses it around restore_upstream. 3015804 is a port of Route Gradle digests through utils::digest #878, so it becomes a no-op once Route Gradle digests through utils::digest #878 lands. The scan/get --dry-run takeover preview is a deliberate follow-up, not part of this PR.

Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants