Skip to content

fix(gem): setup --remove clears bundler's machine-local .bundle/plugin registration - #210

Open
Mikola Lysenko (mikolalysenko) wants to merge 6 commits into
mainfrom
fix/gem-setup-remove-plugin-registration
Open

fix(gem): setup --remove clears bundler's machine-local .bundle/plugin registration#210
Mikola Lysenko (mikolalysenko) wants to merge 6 commits into
mainfrom
fix/gem-setup-remove-plugin-registration

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Defect

socket-patch setup --remove un-wired the Gemfile and deleted the generated plugin files, but left bundler's machine-local plugin registration (.bundle/plugin/index) dangling. Every later bundle install then prints bundler's block forever:

The following plugin paths don't exist: .../.socket/bundler-plugin
... Continuing without installing plugin socket-patch

with a misleading reinstall suggestion — on a project the user just cleanly ejected from.

Root cause

remove_plugin_directive (crates/socket-patch-core/src/setup/gem/update.rs:255) had only two steps: Gemfile un-wire + remove_plugin_files. Nothing ever touched the index bundler writes on first install (hook subscriptions in hooks:, plus plugin_paths:/load_paths: entries). Matches campaign REPORT.md root cause D3/C3 (dangling .bundle/plugin registration after setup --remove).

Campaign repro evidence

  • Original repro agent-b4/eject-setup-remove (bundler 4.0.18) re-run against a gem-b4 image rebuilt from this branch's binary under unique tags (socket-patch-test-{base,gem-b4}:f3-setup-remove; no :latest touched): ===EJECT-SETUP-REMOVE PASS===, out/i3.log now contains zero plugin mentions (only Bundle complete!), and stale .bundle/plugin dir present: no (was yes + the 5-line bundler block).
  • Verification artifacts: session scratchpad f3-verify/ (Dockerfile.gem-b4-f3 + b4 scenario copy with post-fix out/); images left in place for reviewer re-runs.
  • agent-b2 (bundler 2.7.2) shows the same residue pre-fix; the 2.7/4.0 index dialect is identical (bundler YAMLSerializer), covered by unit tests + the real host-bundler 4.0.15 runtime test.

Fix

remove_plugin_directive gains a third step, remove_plugin_registration (crates/socket-patch-core/src/setup/gem/mod.rs), which:

  • resolves the index exactly like Bundler.app_config_path ($BUNDLE_APP_CONFIG, relative resolved against the project root, else <root>/.bundle);
  • parses only bundler's YAMLSerializer dialect (verified against the campaign's real 4.0.18 index) and strips socket-patch from plugin_paths/load_paths/hooks/commands/sources, dropping hook events it empties, keeping every other plugin's lines byte-verbatim;
  • deletes a bundler-installed plugin dir only when inside the plugin root; deletes the index when no plugin remains (bundler regenerates on demand) and prunes emptied dirs; rewrites atomically when other plugins remain; dry-run writes nothing;
  • never rewrites an unrecognized index shape — that surfaces as a new remove-only gem_plugin_registration files[] error naming the remedy bundler plugin uninstall socket-patch (documented in CLI_CONTRACT.md; README --remove row updated).

Scoped away from the sibling lane: no edits to update.rs's PLUGIN_BLOCK/add path or version-gate regions.

Tests (red → green)

Red commit e91f98f (tests only) fails on main's production code exactly as designed; fix commit f8ee008 turns it green:

  • setup_matrix_gem::host_guard::gem_setup_roundtrip_host — seeds the campaign's verbatim index; pre-fix panic "remove must clear bundler's machine-local plugin registration".
  • host_guard::gem_setup_remove_strips_registration_surgically_under_bundle_app_config — multi-plugin index under BUNDLE_APP_CONFIG: other plugin kept byte-verbatim, emptied hook event dropped.
  • plugin_runtime::setup_remove_clears_bundler_plugin_registrationreal host bundler 4.0.15: bundle install registers → setup --remove → next install must print no plugin warning.
  • 14 new -core unit tests (surgical strip, index deletion + dir prune, dry-run, unparseable-index residue-without-corruption, containment gate, Bundler.app_config_path resolution incl. the ruby-image /usr/local/bundle case) + update.rs envelope-entry test.

Review sign-off + follow-ups addressed

Independent reviewer verdict: approve, red/green independently verified via per-file baseline checkout (all 3 new tests fail on da58dee's production code, 16/16 + 69/69 green at HEAD). The review's non-blocking findings are addressed in follow-up commits on this branch, red-first where a red exists:

  1. P2 traversal (0a0295e red, 11d007c fix): Path::starts_with is lexical, so a crafted committed index recording <plugin_root>/../../victim passed the containment gate into remove_dir_all. Red test proves the victim dir was deleted; fix rejects any recorded dir containing a .. component.
  2. P2 hermeticity (75084db): remove_plugin_directive read ambient BUNDLE_APP_CONFIG, so -core unit tests failed spuriously on machines exporting it (verified red with BUNDLE_APP_CONFIG=/usr/local/bundle) and could have touched a real machine-local index. Env now threaded through an explicit remove_plugin_directive_at; tests inject None.
  3. nit fmt (499e498): the branch's 5 rustfmt divergences fixed; workspace --check back to main's pre-existing count.

Final gate (post-follow-ups)

  • cargo test -p socket-patch-core --lib setup::gem — 69 passed, 0 failed (also green with BUNDLE_APP_CONFIG=/usr/local/bundle exported)
  • cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_gem — 16 passed (incl. the real-bundler leg)
  • setup_invariants 29 / cli_setup_silent 6 / cli_parse_setup 19 — all ok
  • cargo clippy --workspace --all-features -- -D warnings — clean

🤖 Generated with Claude Code


Note

Medium Risk
Changes gem setup --remove file I/O and index rewriting on a committed attacker-influenced path; traversal rejection and dialect refusal limit blast radius, and coverage includes real bundler e2e tests.

Overview
Fixes incomplete gem eject: setup --remove used to restore the Gemfile and delete .socket/bundler-plugin but left bundler's machine-local plugin registration in .bundle/plugin/index, so every later bundle install warned about missing plugin paths.

Third remove step: After Gemfile un-wire and plugin file deletion, removal now clears that registration via remove_plugin_registration — resolving the index like Bundler.app_config_path ($BUNDLE_APP_CONFIG, relative paths against the project root), parsing only bundler's YAMLSerializer index dialect, stripping socket-patch from hooks/paths/commands while keeping other plugins byte-verbatim, atomically rewriting or deleting the index, and pruning empty dirs. Unparseable indexes are not guessed; cleanup failure surfaces a gem_plugin_registration files[] error with the bundler plugin uninstall socket-patch fallback.

Safety & docs: Recorded install-dir deletion is containment-gated and rejects .. traversal in index paths. README and CLI_CONTRACT.md document the new behavior and JSON kind.

Reviewed by Cursor Bugbot for commit 499e498. Configure here.

…egistration

Pins the 2026-08 e2e campaign finding (D3/C3, P2): setup --remove unwires
the Gemfile and deletes the generated plugin files, but leaves bundler's
machine-local .bundle/plugin/index registration (hook subscriptions +
plugin/load paths) dangling, so every later bundle install prints
bundler's 5-line "plugin paths don't exist ... Continuing without
installing plugin socket-patch" block with a misleading reinstall
suggestion.

Three failing guards:
- host_guard::gem_setup_roundtrip_host now seeds the index exactly as
  bundler writes it and asserts remove leaves no socket-patch entry
  (index deleted when nothing else is registered);
- host_guard::gem_setup_remove_strips_registration_surgically_under_
  bundle_app_config: surgical strip that preserves another plugin's
  entries verbatim, drops emptied hook events, and follows bundler's
  BUNDLE_APP_CONFIG resolution (relative value resolves against the
  project root);
- plugin_runtime::setup_remove_clears_bundler_plugin_registration:
  real host-bundler flow — install registers, remove must clear, and the
  next bundle install must print neither "plugin paths don't exist" nor
  "Continuing without installing plugin".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n registration

Campaign finding D3/C3 (P2, bundler 2.7.2 + 4.0.18 repros): the unwire
deleted the generated plugin files/stamp and restored the Gemfile, but
never touched the .bundle/plugin/index registration (hook subscriptions
and plugin/load paths) bundler wrote at the wired plugin's first
install, so every later bundle install printed bundler's 5-line
"plugin paths don't exist ... Continuing without installing plugin
socket-patch" block with a misleading reinstall suggestion, forever.

remove_plugin_directive now runs a third step after the Gemfile un-wire
and plugin-file removal (never before — while the directive is wired
the registration is live state, not residue):

- resolves the index location exactly like Bundler.app_config_path:
  $BUNDLE_APP_CONFIG when set (relative values resolve against the
  project root — the official ruby images export
  BUNDLE_APP_CONFIG=/usr/local/bundle), else <root>/.bundle;
- surgically strips socket-patch from the index, parsing only the
  exact dialect bundler's YAMLSerializer writes: plugin_paths/
  load_paths keys, hooks subscriptions (dropping an event key left
  with no subscribers), commands/sources mappings — every other
  plugin's line is kept byte-verbatim, and unknown sections survive
  untouched;
- deletes a bundler-plugin-installed copy only when the recorded dir
  sits inside the plugin root, never a path elsewhere;
- deletes the index outright when no plugin remains (bundler treats a
  missing index as empty) and prunes the emptied plugin/app-config
  dirs (remove_dir, so a .bundle/config keeps its parent);
- an index in any OTHER shape is never rewritten on a guess: the
  cleanup reports residue as a gem_plugin_registration files[] error
  whose message carries the remedy
  (`bundler plugin uninstall socket-patch`).

The cleanup is reported as its own files[] entry
(kind gem_plugin_registration, remove-only, emitted only when a
registration existed) — contract + README updated; a project whose
only leftover is the registration now counts as removable, so a
re-run of setup --remove heals residue left by older CLIs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pes the delete gate

Review finding (P2): Path::starts_with is a purely lexical component
compare, so a crafted committed .bundle/plugin/index recording
plugin_paths <root>/.bundle/plugin/../../victim passes the containment
gate and remove_dir_all deletes outside the plugin root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fore deleting

Path::starts_with is lexical, so the containment gate alone lets a
crafted index's <plugin_root>/../../victim path through to
remove_dir_all. Refuse any recorded dir containing a ParentDir
component — bundler never writes traversal paths, so only hostile
input is rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it-test path

Review finding (P2): remove_plugin_directive read the ambient
BUNDLE_APP_CONFIG, so the -core unit tests exercising it failed
spuriously on machines exporting it (official ruby images export
/usr/local/bundle) — and could have pointed the cleanup at a real
machine-local index outside the tempdir. Thread the env through an
explicit remove_plugin_directive_at (mirroring
remove_plugin_registration_at); the public entry still reads the
process env exactly like bundler. Verified red: with
BUNDLE_APP_CONFIG=/usr/local/bundle,
test_remove_clears_bundler_plugin_registration_entry failed before
this change and passes after (69/69 with and without the export).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review nit: 5 rustfmt divergences introduced by this branch (the
unquote condition width plus 4 test-code spots). cargo fmt --check on
the workspace is back to main's pre-existing count; no touched-file
divergence remains.

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

@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 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.

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

Reviewed by Cursor Bugbot for commit 499e498. Configure here.

let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Gemfile"), GEMFILE).unwrap();
let root_s = root.to_str().unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New tests use bare unwrap

Low Severity

New test setup in this PR uses bare .unwrap() on tempfile::tempdir(), fixture writes, and path conversions. On failure CI only reports a generic unwrap panic with no step context. Prefer .expect("…") with a short description of the setup step being performed.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: Prefer .expect("context") over bare .unwrap() in test code

Reviewed by Cursor Bugbot for commit 499e498. Configure here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant