Sideloading a plugin from a URL validated required manifest fields, warned on
version-field and schema problems, and then installed whatever it had found --
never once comparing the core version. With this, all three routes in refuse on
the same rule: install_plugin (#431/#433), the git-pull update path (the commit
before this one), and now sideloading.
The argument for gating it is not the same as for the other two, and worth
stating. An automatic store update is something that happens TO a user; a
sideload is something they chose. But the floor is not advice about intent --
it is a statement that the plugin cannot run on this core. Letting it through
produces exactly the silent PluginState.ERROR at load that the store gate
exists to prevent, and someone who pasted a URL is no better placed to diagnose
that than someone who pressed Update.
Checked after the download, like the others, because the manifest is the only
place the floor is written. Placed before the move into plugins/, so the
existing `finally` removes the temp tree and nothing half-installed survives a
refusal. The reason string is returned to the caller, so the web UI shows the
same actionable message the store path already gives.
TestSideloadGate pins the three cases the other routes pin: refuses a floor
above this core and leaves nothing behind, still allows a compatible plugin
(the guard against a gate that refuses everything and passes the first test),
and does not block a 2.0.0 floor on a core reporting an untrustworthy version
-- the v3.1.0-reports-1.0.0 population, who must not be locked out.
TestInstallGate's docstring said this route was still ungated. It no longer is,
so it now points at TestSideloadGate rather than describing a gap.
Verified the tests bite: removing the check fails the refusal case.
Full suite 3723 passed, 6 skipped.
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>