Skip to content

Fix gem pair model ignoring custom lockfile and Bundler 1 twins (#749, #751) - #768

Open
Mikola Lysenko (mikolalysenko) wants to merge 10 commits into
mainfrom
agent/fix-gem-loaded-pair-model
Open

Mikola Lysenko (mikolalysenko) wants to merge 10 commits into
mainfrom
agent/fix-gem-loaded-pair-model

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

Fixes #749
Fixes #751

Summary

Hosted gem mode wired the wrong files in two Bundler layouts. In both, the scan reported success (and --vex attested the patch) while Bundler installed the unpatched gem or every frozen install failed. Both now wire the pair Bundler actually loads, or refuse before writing anything.

Root cause

formats::gem::manifest::LoadedManifest is the single model of "which manifest/lock pair does Bundler load", and hosted mode (keep_bundler_loaded_gem_files) and vendored mode (gem_manifest_refusal) both rely on it. It modelled BUNDLE_GEMFILE, but:

Fix

Tests (red → green)

Issue Test Without fix With fix
#749 hosted_memory_engine::a_bundler4_custom_lockfile_is_refused FAILED (rewrote the leftover lock) ok
#749 e2e_redirect_gem_build::gem_hosted_bundler4_custom_lockfile_redirects_nothing (real Bundler 4.0.17) FAILED: redirected: 1, rewrittenFiles: [Gemfile, Gemfile.lock], vex statements: 1 ok; frozen bundle install of the untouched project succeeds
#749 ruby_crawler::loaded_manifest_reads_the_lockfile_setting (disk, no leftover lock; env vs config priority; BUNDLE_IGNORE_CONFIG) new API ok
#749 × #577 manifest::with_lockfile_accepts_only_the_pairs_own_lock (global tier, shadowed by the app config) and ruby_crawler::loaded_manifest_reads_the_global_config_below_local_and_env (bundle config set --global lockfile) new (merge) ok
#749 vendor::gem::a_bundler4_custom_lockfile_is_refused new ok
#749 hosted_memory_engine::a_lockfile_setting_naming_the_default_lock_is_wired (control) ok ok
#751 hosted_memory_engine::a_bundler1_twin_wires_the_gemfile_pair FAILED (wired gems.rb) ok
#751 hosted_memory_engine::a_twin_with_diverging_bundler_majors_is_refused FAILED ok
#751 hosted_memory_engine::a_bundler2_twin_still_wires_gems_rb (control) ok ok
#751 e2e_redirect_gem_build::gem_hosted_bundler1_twin_wires_the_gemfile_and_installs runs on the CI bundler 1.17.3 leg; skips on ≥ 2 skipped locally (Bundler 4)
both manifest.rs unit tests (with_lockfile_*, default_twin_manifest_*, config_lockfile_*), bundled_with_major_reads_the_version_line new ok

Local results

  • cargo clippy --workspace --all-features -- -D warnings: clean.
  • cargo fmt --all -- --check is not clean on main itself (≈500 diffs, and CI has no fmt gate), so I formatted only the hunks I touched.
  • cargo test --workspace --all-features --lib --bins: the core lib has 4848 passed and 4 failed. All four (copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_*, pypi_requirements::wire_failure_rolls_back_*) are chmod-based write-failure tests that can't fail writes when run as root (uid 0) in this sandbox. They don't touch gem code.
  • Integration tests: hosted_memory_engine 33/33, hosted_memory_parity, in_process_vendor and e2e_redirect_gem_stale_install all ok. in_process_redirect has 104 passed and 3 failed, again only the chmod-based write-failure tests (root sandbox).
  • e2e_redirect_gem_build --ignored (real Bundler 4.0.17): new custom-lockfile, dual-boot and gems.rb arms pass.
  • I couldn't run the full cargo test --workspace locally because the sandbox's disk allowance runs out building every integration-test binary. CI runs the full suite.

Merge with main (d502a07)

main gained #577 (Bundler's global config, #621) while this PR was open, and the two overlapped in LoadedManifest. The conflicts are resolved by keeping both. Because Bundler 4 reads lockfile through Bundler.settings, which includes the global file, with_lockfile now takes the global tier too. Without that, bundle config set --global lockfile custom.lock would have slipped past the #749 guard. BundlerEnv carries the global config path, and #621's call sites use it. After the merge: cargo clippy --workspace --all-features -- -D warnings is clean; the gem/ruby core lib tests (253), crawler_ruby_e2e (26), hosted_memory_engine (34), hosted_memory_parity, in_process_vendor (106), e2e_redirect_gem_stale_install, and real-Bundler e2e_redirect_gem_build --ignored (17) all pass. The full core lib has 5010 passing and the same 4 chmod-based failures from running as root here.

Bugbot follow-up (9e7af6e)

  • Lock-only scans are no longer silent. Since Lock inventory reads only Gemfile.lock, so a gems.rb project's gems.locked is invisible and a stale Gemfile.lock is read instead #736, a gem lock bundler loads but socket-patch can't read (a custom BUNDLE_LOCKFILE, an unsupported BUNDLE_GEMFILE, or a diverging twin) left lock inventory empty with no warning. Inventory now adds a gem_lock_unsupported diagnosis, which scan and the in-memory engine report as a run-level warnings[] entry (documented in CLI_CONTRACT.md). Test: lock_inventory::tests::gem_inventory_diagnoses_a_lock_it_cannot_read (red→green). The hosted_memory_engine refusal tests assert the warning again.
  • An empty BUNDLE_LOCKFILE shadows the tiers below it, as Settings#[] does, so an empty env or app-config value now clears a global custom lock.

Notes / follow-ups

🤖 Generated with Claude Code

https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449


Note

Medium Risk
Changes which lockfiles get rewritten in hosted/vendored gem mode and when scans attest patches; wrong behavior previously caused false success and broken frozen installs, but lockfile edits remain high-impact for Ruby projects.

Overview
Hosted and vendored gem flows now follow the manifest/lock pair Bundler actually loads, instead of rewriting ignored files while reporting success.

LoadedManifest gains with_lockfile (Bundler 4 BUNDLE_LOCKFILE from env → app → global config) and default_twin_manifest (uses both locks’ BUNDLED WITH majors to choose Gemfile vs gems.rb under default discovery). Unsupported custom lockfiles refuse with redirect_gem_bundle_lockfile_unsupported / vendored gemfile_not_loaded; diverging twin majors refuse with redirect_gem_twin_bundler_versions_diverge.

Lockfile-only scan no longer treats unreadable gem layouts as “no gems”: inventory emits gem_lock_unsupported when gem files exist but no lock socket-patch can read.

Docs (CLI_CONTRACT.md, ecosystems.md), hosted engine candidate filtering, VEX discovery messaging, and e2e/memory tests cover the new behaviors. Minor unrelated refactor: shared sha1_hex_of / sha256_hex_of helpers in a few JVM/Gradle paths.

Reviewed by Cursor Bugbot for commit cf746ec. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
Hosted mode wired the wrong gem files in two Bundler layouts, so the
scan reported success (and its VEX attested a patch) while Bundler
installed the unpatched gem or frozen installs failed:

- Bundler 4's custom lockfile (BUNDLE_LOCKFILE, env or .bundle/config)
  was ignored, so the lock Bundler reads was never pinned (#749). A
  lockfile naming anything but the pair's own default lock is now
  refused in hosted (redirect_gem_bundle_lockfile_unsupported) and
  vendored (gemfile_not_loaded) mode before any write.
- A Gemfile + gems.rb twin always followed Bundler >= 2 and wired
  gems.rb, but Bundler 1.x loads the Gemfile (#751). A twin whose locks
  say BUNDLED WITH 1.x is now wired through the Gemfile pair, and twin
  locks that disagree on the major are refused
  (redirect_gem_twin_bundler_versions_diverge).

Assisted-by: Claude Code:claude-opus-5-5
Two host capstones in e2e_redirect_gem_build: a Bundler 4 project with
`lockfile custom.lock` (and a leftover Gemfile.lock) redirects and
attests nothing and still installs frozen (#749), and a Bundler 1.x
Gemfile + gems.rb twin is wired through the Gemfile and a fresh
checkout installs the patched gem (#751). Each skips on the Bundler
line it does not apply to.

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 4, 2026 09:53
@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.

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

Copy link
Copy Markdown
Collaborator Author

Ready for review at 731f489.

  • CI: 402/402 non-skipped checks green on the current head (9/9 workflows, path-filtered).
  • Bugbot: reviewed 731f489 and found no issues. There are no unresolved review threads.
  • Mergeable: yes, no conflicts with main. The diff is 10 files, all in the gem lane, plus docs and changelog.
  • For the reviewer: look at the LoadedManifest pair model in formats/gem/manifest.rs (the configured BUNDLE_LOCKFILE and the BUNDLED WITH major of a Gemfile/gems.rb twin) and the matching ruby_crawler.rs changes.

Generated by Claude Code

Release notes are written when a release is cut, from the merged PR
log and the code, so PRs no longer edit CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 5, 2026
Resolve the overlap with #577 (Bundler global config, #621): classify
gained the global tier, so with_lockfile takes it too. Bundler 4 reads
`lockfile` through Bundler.settings, which includes ~/.bundle/config,
so `bundle config set --global lockfile custom.lock` is now refused like
the env and app-config spellings instead of slipping past #749's guard.
BundlerEnv carries the global config path; main's positional
bundler_loaded_manifest_with_env call sites move to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

coverage (the Linux test gate) failed on the merge commit d502a07. Its one failing target is -p socket-patch-cli --lib. coverage was green on 731f489, before the merge with main.

I can't see which test failed yet. The job log's download URL (productionresultssa5.blob.core.windows.net) is blocked by my sandbox's network policy, and the MCP log tool returns only the last ~5000 of the log's 12022 lines. The CLI lib test section is earlier than that, so the panic isn't in what I can read. The check annotations only carry the exit code.

What I checked locally on d502a07:

  • cargo test -p socket-patch-cli --lib with default features and with --all-features: 835/835 pass.
  • With CI's SOCKET_PATCH_GO_E2E_REQUIRED=1 / SOCKET_PATCH_GO_E2E_VERSION=1.24 (Go 1.24.7): 835/835 pass.
  • The same test binary run as a non-root user: 835/835 pass.
  • This PR doesn't touch crates/socket-patch-cli/src. That code is identical to main's.

Next: the macOS and Windows test legs run the same CLI lib tests on this commit, and I'll use them to tell a real failure from an instrumentation or timing one. If anyone can open the job log (coverage → "Run tests with coverage", search for FAILED), the failing test name and panic would let me fix it directly.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Found it, and this PR doesn't cause it: main is red at 4646693. coverage and test-release test the PR merged into the current main. Both fail on the same two CLI lib tests, and they fail identically on main alone:

  • commands::vex_consumed::tests::hosted_expands_alias_only_copies (vex_consumed.rs:771, assert!(installed.is_empty()))
  • commands::vex_consumed::tests::hosted_reuses_expanded_npm_copies_and_merges_alias_variants (vex_consumed.rs:718, assert_eq!(installed_again, installed))

Cause: the two tests came in with #738 (#356) and assume the name-keyed resolver find_manifest_package_copies_reusing can't see aliased copies. #605 (#601/#603, merged as 4646693) made that resolver probe store entries' bundled trees, so it now returns the aliases, the nested alias and every store peer variant itself. I ran both tests on main with the asserts relaxed:

  • calls == []: Fix agent mode skipping npm-aliased copies (#356) #738's alias expansion is no longer needed.
  • The final paths set is the complete, correct one: {host/…/lp, left-pad(peer@1.0.0), left-pad(peer@1.0.1)} in the first test, and every root, alias, nested-alias and peer copy in the second.

So the behaviour is right and only the tests' premise is stale.

Proposed patch (for main, not this PR):

  • In hosted_expands_alias_only_copies, replace assert!(installed.is_empty()) and assert_eq!(calls, vec[vec[alias.clone()]]) with:
    • installed[&purl] (sorted) equals peers + [alias] (sorted);
    • calls.is_empty();
    • paths == installed[&purl].
  • In hosted_reuses_expanded_npm_copies_and_merges_alias_variants:
    • Expect installed_again[&purl] to already contain nm/lp, host/node_modules/lp and the nested peers.
    • Replace calls.len() == 1 / the alias-inputs check with calls.is_empty().
    • Keep the final sorted-set and canonical-uniqueness assertions.
    • The earlier nm/lp step (calls == [[alias]]) needs the same update if the resolver also finds that alias. On main it currently doesn't fail first, so check it when applying.

I'm not adding this to #768. It's in code unrelated to the gem change, and no fix for it is open yet. Once main is green, I'll merge it into this branch and re-run CI.


Generated by Claude Code

#605 taught the name-keyed npm resolver to probe bundled store
trees, so it now finds aliased copies (node_modules/lp) and a nested
host's store peers itself. Two vex_consumed tests from #738 assumed
that set never held aliases, so main's CI went red after both merged.

The tests now feed the alias-free set explicitly to keep covering
alias expansion, and also check the resolver's own set reaches the
same copies with no duplicates. No production code changes.

Assisted-by: Claude Code:claude-opus-5-5
(cherry picked from commit 40dac07)
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: pushed 8c2ba59.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


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.

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

Mikola Lysenko (mikolalysenko) commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review.


Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 5, 2026
Conflicts resolved:
- ruby_crawler.rs: kept this branch's BundlerEnv and main's (#736)
  bundler_loaded_manifest_in / bundler_loaded_lock_in. The memory branch
  of bundler_loaded_manifest_in now also layers BUNDLE_LOCKFILE
  (with_lockfile), and bundler_loaded_lock_in follows the #751 twin rule
  (Gemfile.lock for a bundler-1.x twin, no lock for a twin whose locks
  disagree on the major), so the lock readers main routed through it
  (inventory, ledger recovery, VEX) agree with the pair the rewriter wires.
- hosted/engine.rs: keep_bundler_loaded_gem_files uses main's shared
  resolver plus this branch's twin/lockfile handling.
- vex/discover/gem.rs: the "no loaded lock" diagnostic no longer blames
  only BUNDLE_GEMFILE.
- CLI_CONTRACT.md: both texts (main's Gradle confirmation sentence and this
  branch's new gem refusal codes).
- e2e_redirect_gem_build.rs: both sets of Driver variants.
- hosted_memory_engine.rs: both sets of tests. Since #736 the memory
  engine finds gem candidates only through the lock bundler loads, so a
  custom BUNDLE_LOCKFILE or a twin with diverging bundler majors yields
  no gem candidate (same as an unsupported BUNDLE_GEMFILE on main); those
  two tests now assert nothing is redirected/written, and the refusal
  warnings are covered by new engine unit tests.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


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.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unsupported gem layouts silent on lock-only scans
    • Modified the condition in hosted/engine.rs to also check for gem manifest files presence, ensuring keep_bundler_loaded_gem_files runs even when there are no gem candidates, allowing warnings to be surfaced for unsupported layouts on lock-only scans.

Create PR

Or push these changes by commenting:

@cursor push 992984be56
Preview (992984be56)
diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs
--- a/crates/socket-patch-core/src/hosted/engine.rs
+++ b/crates/socket-patch-core/src/hosted/engine.rs
@@ -545,7 +545,11 @@
             }
         }
     }
-    if candidates.iter().any(|c| c.dep.ecosystem == "gem") {
+    if candidates.iter().any(|c| c.dep.ecosystem == "gem")
+        || GEM_MANIFEST_FILES
+            .iter()
+            .any(|&f| out.files.contains_key(f))
+    {
         keep_bundler_loaded_gem_files(view, &mut out).await;
     }
     // A Gradle build: every script, catalog and lock file its script graph

You can send follow-ups to the cloud agent here.

Comment thread crates/socket-patch-core/src/crawlers/ruby_crawler.rs Outdated
Comment thread crates/socket-patch-core/src/formats/gem/manifest.rs
Lock inventory reads only the lock bundler loads (#736), so a custom
BUNDLE_LOCKFILE, an unsupported BUNDLE_GEMFILE or a Gemfile + gems.rb
twin whose locks disagree on the bundler major left a lockfile-only
scan with no gem entries and no warning: the per-candidate redirect
refusals never ran because there were no gem candidates. Surface the
reason as a gem_lock_unsupported diagnosis on the inventory's existing
layout-refusal channel, which scan and the in-memory engine already
turn into run-level warnings.

An empty BUNDLE_LOCKFILE now shadows the tiers below it, as
Settings#[] does, instead of letting a global custom lock through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449
utils::digest's production_digests_go_through_the_helpers fails on
main: three Gradle/JVM files compute digests inline. That makes
`coverage`, `test` and `test-release` red on every PR. #878 routes
them through utils::digest. This is the same change, ported so this
PR's CI is green. It becomes a no-op once #878 lands.

Claude-Session: https://claude.ai/code/session_01LS9AJhpVngXZxng8TRA2Kd
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 4d8cad2)
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

test (windows-latest) failed on 9e7af6e, but not because of this PR. The failure is utils::digest::tests::production_digests_go_through_the_helpers, which also fails on main (9c43dfc) on Linux. crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs compute digests inline and aren't on the pending list. #878 fixes it and is still open, so I've ported its change in cf746ec, cherry-picked from #885's 4d8cad2. It becomes a no-op once #878 lands. With it, the digest check and the Gradle/JAR/sidecar tests pass locally, and clippy is clean.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


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 cf746ec. Configure here.

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

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at cf746ec (cf746ec8b25b87cb6f7fb0a2ae8e70416c116f37).

  • CI: 506/506 workflow checks green on the head commit (6 skipped by matrix rule). The only non-green entries are the 4 CodeQL default-setup Analyze jobs (run "PR Fix gem pair model ignoring custom lockfile and Bundler 1 twins (#749, #751) #768"). GitHub cancelled them during today's Actions runner outage, and GitHub doesn't allow re-running them ("This workflow run cannot be retried"). They'll run again on the next push.
  • Bugbot: reviewed cf746ec with no new issues. Both earlier findings are resolved, and no review threads are open.
  • Mergeable against main, with no conflicts.
  • Reviewer note: this changes which Gemfile/lockfile pair hosted and vendored gem modes rewrite (BUNDLE_LOCKFILE, Bundler 1 gems.rb twins). Please look most closely at the new refusal codes.

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

3 participants