fix(gem): setup --remove clears bundler's machine-local .bundle/plugin registration - #210
Conversation
…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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
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(); |
There was a problem hiding this comment.
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)
Triggered by learned rule: Prefer .expect("context") over bare .unwrap() in test code
Reviewed by Cursor Bugbot for commit 499e498. Configure here.


Defect
socket-patch setup --removeun-wired the Gemfile and deleted the generated plugin files, but left bundler's machine-local plugin registration (.bundle/plugin/index) dangling. Every laterbundle installthen prints bundler's block forever: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 inhooks:, plusplugin_paths:/load_paths:entries). Matches campaign REPORT.md root cause D3/C3 (dangling.bundle/pluginregistration aftersetup --remove).Campaign repro evidence
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:latesttouched):===EJECT-SETUP-REMOVE PASS===,out/i3.lognow contains zeropluginmentions (onlyBundle complete!), andstale .bundle/plugin dir present: no(wasyes+ the 5-line bundler block).f3-verify/(Dockerfile.gem-b4-f3 + b4 scenario copy with post-fixout/); images left in place for reviewer re-runs.YAMLSerializer), covered by unit tests + the real host-bundler 4.0.15 runtime test.Fix
remove_plugin_directivegains a third step,remove_plugin_registration(crates/socket-patch-core/src/setup/gem/mod.rs), which:Bundler.app_config_path($BUNDLE_APP_CONFIG, relative resolved against the project root, else<root>/.bundle);YAMLSerializerdialect (verified against the campaign's real 4.0.18 index) and strips socket-patch fromplugin_paths/load_paths/hooks/commands/sources, dropping hook events it empties, keeping every other plugin's lines byte-verbatim;gem_plugin_registrationfiles[]error naming the remedybundler plugin uninstall socket-patch(documented inCLI_CONTRACT.md; README--removerow updated).Scoped away from the sibling lane: no edits to
update.rs'sPLUGIN_BLOCK/add path or version-gate regions.Tests (red → green)
Red commit
e91f98f(tests only) fails onmain's production code exactly as designed; fix commitf8ee008turns 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 underBUNDLE_APP_CONFIG: other plugin kept byte-verbatim, emptied hook event dropped.plugin_runtime::setup_remove_clears_bundler_plugin_registration— real host bundler 4.0.15:bundle installregisters →setup --remove→ next install must print no plugin warning.-coreunit tests (surgical strip, index deletion + dir prune, dry-run, unparseable-index residue-without-corruption, containment gate,Bundler.app_config_pathresolution incl. the ruby-image/usr/local/bundlecase) +update.rsenvelope-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:0a0295ered,11d007cfix):Path::starts_withis lexical, so a crafted committed index recording<plugin_root>/../../victimpassed the containment gate intoremove_dir_all. Red test proves the victim dir was deleted; fix rejects any recorded dir containing a..component.75084db):remove_plugin_directiveread ambientBUNDLE_APP_CONFIG, so-coreunit tests failed spuriously on machines exporting it (verified red withBUNDLE_APP_CONFIG=/usr/local/bundle) and could have touched a real machine-local index. Env now threaded through an explicitremove_plugin_directive_at; tests injectNone.499e498): the branch's 5 rustfmt divergences fixed; workspace--checkback 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 withBUNDLE_APP_CONFIG=/usr/local/bundleexported)cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_gem— 16 passed (incl. the real-bundler leg)setup_invariants29 /cli_setup_silent6 /cli_parse_setup19 — all okcargo clippy --workspace --all-features -- -D warnings— clean🤖 Generated with Claude Code
Note
Medium Risk
Changes gem
setup --removefile 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 --removeused to restore the Gemfile and delete.socket/bundler-pluginbut left bundler's machine-local plugin registration in.bundle/plugin/index, so every laterbundle installwarned 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 likeBundler.app_config_path($BUNDLE_APP_CONFIG, relative paths against the project root), parsing only bundler'sYAMLSerializerindex dialect, strippingsocket-patchfrom 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 agem_plugin_registrationfiles[]error with thebundler plugin uninstall socket-patchfallback.Safety & docs: Recorded install-dir deletion is containment-gated and rejects
..traversal in index paths. README andCLI_CONTRACT.mddocument the new behavior and JSONkind.Reviewed by Cursor Bugbot for commit 499e498. Configure here.