Skip to content

Fix Composer 1 packages moved by composer/installers (#463) - #1723

Merged
Mikola Lysenko (mikolalysenko) merged 5 commits into
mainfrom
agent/fix-composer1-installer-paths
Oct 26, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 5 commits into
mainfrom
agent/fix-composer1-installer-paths

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

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

Fixes #463

Summary

On Composer 1, socket-patch could not find packages that composer/installers installs outside vendor/, such as WordPress plugins and Drupal modules. Agent scan skipped them as "not installed" and exited 0, and get --mode agent reported them missing. Hosted vex attested not_affected from the lock pin without checking the unpatched copy that is actually installed. Those packages are now found where composer/installers put them.

Root cause

resolve_package_dir (crates/socket-patch-core/src/crawlers/composer_crawler.rs) trusts installed.json's install-path. When an entry has none, it always uses vendor/<ns>/<name>. Composer 2 records install-path for every package, but Composer 1 never does, so a package composer/installers moved was invisible. crawl_all (scan, vex) and find_by_purls (apply) both resolve through this function.

Fix

For an entry with no install-path, when composer/installers is in installed.json and not switched off by the root extra.installer-disable, the crawler works out the directory the way the plugin's BaseInstaller::getInstallPath does:

  • the first root extra.installer-paths entry (in file order) whose rules name the package, its type: or its vendor:;
  • otherwise the built-in location for the type.

{$name}, {$vendor} and {$type} are substituted, and the package's extra.installer-name replaces {$name}. That directory is tried before vendor/<ns>/<name> (it is the copy the app loads) and must stay inside the project, like an install-path.

Scope decisions, from /code-review high on the first cut:

  • Only the WordPress and Drupal types composer/installers handles are relocated. Their installers keep {$name} unchanged. Frameworks like CakePHP rewrite the name, so guessing their directory could point the patch at the wrong place. Those packages stay unresolved exactly as on main.
  • wordpress-core and other types the plugin doesn't handle stay in vendor/. Drupal's multisite type uses its real drupal-drupal-multisite key.
  • installer-name must be a single safe path segment, because installed.json is untrusted.
  • composer.json is read only when an entry lacks install-path, so Composer 2 trees pay nothing.

Known limits (not regressions, also missed on main): a vendor dir moved outside the project (COMPOSER_VENDOR_DIR=/opt/...), where the root composer.json can't be located from the vendor path; composer/installers installed only globally in $COMPOSER_HOME; composer-installers-extender installer-types. A possible follow-up is to read the real directory from Composer 1's vendor/composer/autoload_*.php maps.

Tests

Per-issue checklist:

  • On Composer 1, composer/installers packages (e.g. WordPress plugins) are invisible to the crawler: agent scan skips them as "not installed" and hosted vex attests them without checking the unpatched installed bytes #463, the agent scan / apply / vex discovery from the issue's exact repro (type:wordpress-plugin → wp-content/plugins/{$name}/):
    • core crawler_composer_e2e::composer1_installer_paths_type_rule_finds_relocated_package, covering crawl_all and find_by_purls;
    • CLI e2e_composer::scan_discovers_composer1_installer_paths_package.
    • Both fail on main (the CLI one reports 1 package instead of 2, with the plugin missed) and pass here.
  • Variants:
    • name, vendor: and first-match rules, bare-string rules, installer-name, and the WordPress/Drupal default locations;
    • preferred over a stale vendor/ copy;
    • no composer/installers, library, wordpress-core and cakephp-plugin types, and installer-disable (true, a list, "all") all stay in vendor/;
    • escaping paths and multi-segment installer-name are rejected.
    • Red→green shown for the first 4 tests against main, and for the 2 review-fix tests against the previous commit.

Commands run locally:

  • cargo fmt --all -- --check: ok.
  • cargo clippy --workspace --all-features -- -D warnings: ok.
  • cargo test -p socket-patch-core --all-features --no-fail-fast: every suite passes except 4 lib tests (copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_maps_error_and_leaves_lock_untouched, pypi_requirements::wire_failure_rolls_back_already_written_files). Those depend on chmod denials, which root ignores, and the agent container runs as root. This PR doesn't touch those files.
  • cargo test -p socket-patch-cli --all-features --lib and --test e2e_composer e2e_composer_vendor_dir_hints e2e_composer_version_identity e2e_vendor_composer_crlf: ok.
  • The full cargo test --workspace didn't fit in the agent container's disk allowance, so CI covers the rest.
  • Not changed: the npm/pypi/gem wrappers. They only dispatch to the binary.

https://claude.ai/code/session_01MG72xjJojwpExvkktfHyTs


Note

Medium Risk
Changes where scan/apply resolve on-disk package paths for patching, including path inference from untrusted composer.json/installed.json, though containment and installer-name guards limit write targets.

Overview
Fixes #463: Composer 1 projects with composer/installers no longer miss packages installed outside vendor/ (e.g. WordPress plugins under wp-content/plugins/) when installed.json has no install-path.

The Composer crawler adds InstallerLayout to mirror composer/installers when any entry lacks install-path and composer/installers is present: it reads root extra.installer-paths / installer-disable, applies built-in WordPress/Drupal default locations, renders {$name} / {$vendor} / {$type} (and extra.installer-name), and prefers the relocated directory over vendor/<ns>/<name> when both exist. crawl_all and find_by_purls now resolve via ordered path candidates and first_existing_dirs; contain_in_project and single-segment installer-name checks keep untrusted metadata from escaping the project. Composer 2 trees that already record install-path skip reading composer.json.

Coverage includes core e2e cases (rules, defaults, disable, stale vendor copy, path rejection) and a CLI scan e2e asserting relocated packages reach patch lookup.

Reviewed by Cursor Bugbot for commit a841498. Configure here.


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
On Composer 1, installed.json has no install-path, so the crawler
looked for every package under vendor/<ns>/<name>. Packages that
composer/installers puts elsewhere (WordPress plugins, Drupal
modules, any extra.installer-paths target) were never found: agent
scan skipped them as "not installed" and exited 0, apply reported
them missing, and hosted vex attested not_affected from the lock pin
without checking the unpatched copy actually installed.

When composer/installers is installed and an entry has no
install-path, work out its directory the way the plugin does: the
first root installer-paths entry whose rules name the package, its
type: or its vendor:, with {$name}/{$vendor}/{$type} substituted
(the package's installer-name replaces {$name}), else the built-in
WordPress and Drupal locations. That directory is preferred over a
leftover vendor/ copy, and must stay inside the project like an
install-path. scan, apply and vex share the lookup.

Fixes #463

Assisted-by: Claude Code:claude-opus-5-5
End-to-end regression for #463: a Composer 1 project whose WordPress
plugin lives under extra.installer-paths must be counted by scan and
sent to the patch lookup. Fails on main (1 package found, the plugin
is missed) and passes with the crawler fix.

Assisted-by: Claude Code:claude-opus-5-5
The first cut relocated any <framework>-<kind> type and filled
{$name} with the raw package name. composer/installers only moves
types its installers list, and many frameworks rewrite {$name}
(CakePHP camel-cases it), so a guessed directory could be wrong and
was preferred over the real vendor/ copy.

Relocate only the WordPress and Drupal types composer/installers
handles (names kept as is), using DrupalInstaller's real
drupal-drupal-multisite key. Honour extra.installer-disable, require
an installer-name to be one safe path segment, and skip reading
composer.json when every entry already records an install-path
(Composer 2).

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

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: labeled Ready for review at 528220e.

  • CI: 88/88 checks on 528220e passed or skipped (38 success, 50 skipped). No main-wide failures.
  • Bugbot: reviewed 528220e, no findings.
  • Mergeable against main, no conflicts. CHANGELOG.md is untouched.
  • Reviewers: look at the path inference in composer_crawler/oracle.rs (it mirrors composer/installers installer-paths, installer-name and installer-disable) and at the containment check that keeps inferred paths inside the project.
  • Slack announcement could not be sent this run (the Slack send tool was unavailable), so the next run will retry it.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] Correction to the review pointer above: the composer/installers path inference (InstallerLayout::load/locate, installer-paths, installer-name, installer-disable) and the containment check live in crates/socket-patch-core/src/crawlers/composer_crawler.rs. composer_crawler/oracle.rs is the test-only equivalence oracle and only switched to the new candidate-list signature.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does: Composer 1 doesn't record install-path in installed.json, so the crawler used to look only in vendor/<ns>/<name>. When composer/installers had moved a package (WordPress plugins, Drupal modules, root extra.installer-paths), the crawler missed it. This PR works out the composer/installers location again from the root installer-paths / installer-disable, the package type and its installer-name. It checks that location first and falls back to vendor/. Composer 2 trees, which do record install-path, are unchanged.

Risk: low. The new path only runs for entries with no install-path when composer/installers is installed. It only covers WordPress and Drupal types, whose installers keep {$name} unchanged; any other type stays in vendor/. Every inferred path must stay inside the project.

Look here:

  • INSTALLER_DEFAULT_LOCATIONS:`` the built-in WordPress/Drupal locations (drupal-drupal-multisite is the right type for DrupalInstaller's `drupal-multisite` key)
  • InstallerLayout::locate:``` installer-paths rule matching (name / type: / `vendor:`, first match wins, as in `BaseInstaller::mapCustomInstallPaths`); an unsafe `installer-name` is rejected and the package falls back to `vendor/`
  • render_installer_path:```` an unknown placeholder or a NUL means no relocation
  • resolve_package_dir / first_existing_dirs:````` the relocated directory comes before the vendor/ copy, and all candidates are still checked on disk in one batch

Verified: I read the full diff and checked it against composer/installers' BaseInstaller::getInstallPath ({$type} is the full type, installer-name overrides {$name}). The first_existing_dirs iterator stays in step with the candidate lists. CHANGELOG.md is untouched. Locally on 528220e: cargo test -p socket-patch-core --test crawler_composer_e2e (52 passed) and --lib composer (208 passed). The 8 new crawler tests cover type/name/vendor rules, default locations, stale vendor/ copies, an escaping path, disabled installers and an unsafe installer-name, plus one CLI e2e test. CI: ci-ok and clippy green (88/88 passed or skipped). Bugbot: no findings on 528220e. No unresolved threads.

Changes I made: none.

Open questions: none.

Auto-merge is armed: approving sends this straight to the merge queue.


Generated by Claude Code

composer/installers turns every installer off when installer-disable
is "*", or is a list holding true, "all" or "*". This parser treated
"*" as a framework name and dropped true inside a list, so it still
guessed a WordPress or Drupal directory for a package Composer left
in vendor/, and could patch a stray copy there instead. Framework
names now also match case-sensitively, as upstream does.

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

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[final reviewer] Disabled auto-merge. This PR was armed only on the strength of the final-review brief, and the head has gained non-merge commits since that brief (review fixes pushed today). I'll write a fresh brief once those pushes settle and CI and Bugbot are green on the new head, then re-arm. The Ready for review label is still on; the burn-down should confirm it against the new head.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Burn-down agent: Ready for review at a841498.

  • CI: 56/56 checks on a841498 passed or skipped (37 success, 19 skipped); mergeable, no conflicts. No main-wide failures. CHANGELOG.md untouched.
  • Bugbot: reviewed a841498, no findings; all review threads resolved.
  • Re-confirmed at the new head (previous label comment was at 528220e). Tanmay's review thread is resolved. Auto-merge was disabled by the final reviewer pending a fresh brief.
  • Slack announcement not sent this run (Slack send unavailable); the next run will retry.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does: On Composer 1 (installed.json has no install-path), the crawler now works out where composer/installers put a package, the same way the plugin does (#463). It uses the root composer.json extra.installer-paths: first match by name, type: or vendor:, with {$name}/{$vendor}/{$type}, installer-name and installer-disable handled. When nothing matches, it uses the WordPress/Drupal default locations. That directory is tried before vendor/<ns>/<name> in crawl_all and find_by_purls. Composer 2 trees are unchanged.

Risk: low to medium.

  • It only runs when composer/installers is installed and some entry has no install-path.
  • The computed directory is a patch write target built from project files the tool can't trust, so containment is the key check. A template that is absolute, uses .., has a drive prefix or resolves to the project root falls back to vendor/, and installer-name must be a single safe segment.

Look here

  • composer_crawler.rs:64:`` INSTALLER_DEFAULT_LOCATIONS. Only WordPress and Drupal types are relocated.
  • :95 InstallerLayout::load and :125 locate: rule matching, the installer-name check, containment.
  • :167 parse_installer_disable and :216 render_installer_path, which refuses unknown {$…} placeholders and NUL.
  • :958 contain_in_project and :998 resolve_package_dir: the relocated directory is preferred over vendor/.
  • tests/crawler_composer_e2e.rs: 8 new tests.

Verified

  • A subagent read the full diff and checked it against composer/installers' getInstallPath, mapCustomInstallPaths, templatePath and removeDisabledInstallers. Rule order holds because serde_json uses preserve_order here.
  • Tests passed:
    • cargo test -p socket-patch-core composer: all pass (crawler_composer_e2e 52/52).
    • socket-patch-cli --test e2e_composer: 23/23.
  • With the crawler and oracle reverted to main, 5 of the new tests fail. The other 3 are negative controls.
  • CI is green on a841498 (ci-ok and clippy). Bugbot is clean and there are no open threads. CHANGELOG.md is untouched.

Changes I made: none.

Open questions (none blocking; these only cause missed packages, never wrong ones)

  • Laravel, Magento and the other frameworks the plugin handles still fall back to vendor/ when they use installer-paths. So does the Drupal recipe type.
  • Containment is lexical only, so a symlinked wp-content/plugins pointing outside the root passes. install-path already has the same gap.

Auto-merge is armed, so approving sends this straight to the merge queue.


Generated by Claude Code

Merged via the queue into main with commit 5f3c7d5 Oct 26, 2026
56 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the agent/fix-composer1-installer-paths branch October 26, 2026 18:37
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