fix(store): gate install_from_url, the last ungated route in (#510)

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>
This commit is contained in:
Chuck
2026-09-01 09:54:17 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 45d912142c
commit 2ef70bce0b
2 changed files with 90 additions and 2 deletions
+20
View File
@@ -1602,6 +1602,26 @@ class PluginStoreManager:
'error': f'Manifest missing required fields: {", ".join(missing_fields)}'
}
# Refuse a plugin that needs a newer core than this one, exactly as
# _install_plugin_impl does after its download. Sideloading is an
# explicit act rather than an automatic store update, but the floor
# is not advice about intent -- it is a statement that the plugin
# cannot run here, and letting it through produces the same silent
# PluginState.ERROR at load. This was the last of the three routes
# in that skipped the check.
#
# Before the move, so the `finally` below removes the temp tree and
# nothing half-installed is left behind.
from src import __version__ as core_version
from src.plugin_system import compatibility
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
self.logger.error(
"Refusing to install %s from %s: %s",
plugin_id, repo_url, reason)
return {'success': False, 'error': reason}
# Validate version fields consistency (warnings only, not required)
validation_errors = self._validate_manifest_version_fields(manifest)
if validation_errors: