mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(store): gate the git-pull update path (#508)
* fix(store): gate the git-pull update path `install_plugin` gates every route that re-downloads, `_reinstall_with_rollback` included. `update_plugin` has one branch that re-downloads nothing: a git checkout pulls in place, installs dependencies, and returns True. A pull could therefore deliver a manifest flooring above this core and nothing would notice until the plugin failed to load — which surfaces as one line in the journal and a display that silently stopped appearing. Checked after the pull rather than before it, for the same reason `_install_plugin_impl` checks after the download: the registry carries no compatibility field, so the incoming floor is only knowable once the new commit is on disk. Undone with `git reset --hard` to the pre-pull commit rather than by removing the directory. This is a live checkout, the old commit is still in the object store, and the reset leaves the user on the exact version they were already running — the same promise `_reinstall_with_rollback` makes, reached by the means this path actually has, with no window where the plugin directory does not exist. An unreadable manifest allows: it is not evidence of a floor. Scope, stated plainly: monorepo plugins install as archives and update through `_reinstall_with_rollback`, so they were already gated. Only registry entries with no `plugin_path` reach this branch. It is closed anyway because the sunset rule in the plugins repo's `08-shared-sports-code.md` names, as condition 3, that the core enforces the floor "at install/update time" — and B6 rests on that being true rather than merely written down. `install_from_url` is still ungated; the tests say so rather than letting the next reader assume otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(store): do not pull what the gate cannot un-pull Review of the gate found a data-loss path it had introduced, plus two smaller scope errors. All three from CodeRabbit on #508. **The stash failure was load-bearing and was not treated as one.** update_plugin stashes local changes before pulling; when that stash failed or timed out it logged a warning and pulled anyway. That was harmless while nothing ever undid a pull. It is not harmless now: the gate's rollback is `git reset --hard`, which discards uncommitted tracked edits -- exactly the edits the stash existed to protect. A pull does not refuse on a dirty tree as long as the incoming commit touches other files, so the sequence completed silently: pull succeeds, gate refuses, reset takes the user's work with it. update_plugin now returns before pulling unless the tree was already clean or was successfully stashed. Refusing costs an update in a case that had already gone wrong; the alternative costs data. That also makes `--hard` safe by construction in _gate_pulled_commit, and its comment now says so rather than observing it in passing. Pinned by test_a_failed_stash_stops_the_update_before_pulling, which writes a local edit, forces the stash to fail, and asserts both that HEAD did not move and that the edit is still on disk. Verified it bites: with the new guard removed the file comes back as `class P: pass`, the edit gone. **_HAS_GIT could take the module down instead of skipping it.** With no git on PATH, subprocess.run raises FileNotFoundError, and this runs at import time -- before skipif can act, so the whole file errors rather than skipping. Now catches OSError. **The doc overclaimed the gate's reach.** It said the floor is enforced on "every route that installs or updates" while the same passage notes install_from_url is ungated. Both spots now scope the claim to registry-managed installs and the two supported update paths, and name the sideload exception. Full suite 3720 passed, 6 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2583,6 +2583,85 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
def _gate_pulled_commit(self, plugin_id: str, plugin_path: Path,
|
||||
previous_sha: Optional[str]) -> bool:
|
||||
"""Apply the compatibility gate to a commit that arrived via git pull.
|
||||
|
||||
Every other route into an installed plugin goes through
|
||||
``install_plugin``, which gates in ``_install_plugin_impl``. This one
|
||||
did not: a ``git pull`` could deliver a manifest flooring above this
|
||||
core and nothing would notice until the plugin failed to load, which
|
||||
surfaces as one line in the journal and a scoreboard that silently
|
||||
stopped appearing.
|
||||
|
||||
Checked after the pull rather than before it, for the same reason
|
||||
``_install_plugin_impl`` checks after the download: the registry
|
||||
carries no compatibility field, so the incoming floor is only knowable
|
||||
once the new commit is on disk.
|
||||
|
||||
Undone with ``git reset --hard`` rather than by removing the directory.
|
||||
This is a live checkout, the previous commit is still in the object
|
||||
store, and the reset leaves the user on the exact version they were
|
||||
already running -- the same promise ``_reinstall_with_rollback`` makes,
|
||||
reached by the means this path actually has. It is also the gentler
|
||||
option: no window in which the plugin directory does not exist, and no
|
||||
``.standalone-backup-`` debris if the process dies mid-way.
|
||||
|
||||
A manifest that cannot be read is not evidence of incompatibility, so
|
||||
it allows. ``compatibility.check`` refuses only on evidence for the
|
||||
same reason: a wrong refusal breaks a working install, while a wrong
|
||||
allowance degrades to exactly the behaviour this path had before the
|
||||
gate existed.
|
||||
"""
|
||||
manifest_path = plugin_path / "manifest.json"
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as mf:
|
||||
manifest = json.load(mf)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read %s after updating %s (%s); allowing the "
|
||||
"update, as an unreadable manifest declares no floor",
|
||||
manifest_path, plugin_id, e)
|
||||
return True
|
||||
|
||||
from src import __version__ as core_version
|
||||
from src.plugin_system import compatibility
|
||||
|
||||
compatible, reason = compatibility.check(manifest, core_version)
|
||||
if compatible:
|
||||
return True
|
||||
|
||||
self.logger.error("Refusing the update to %s: %s", plugin_id, reason)
|
||||
|
||||
if not previous_sha:
|
||||
self.logger.error(
|
||||
"Cannot roll %s back: the commit it was on before the pull is "
|
||||
"unknown. It is now on a version this core cannot run — "
|
||||
"reinstall it from the plugin store.", plugin_id)
|
||||
return False
|
||||
|
||||
# Safe by construction: update_plugin returns before pulling unless the
|
||||
# tree was clean or successfully stashed, so there are no uncommitted
|
||||
# tracked edits for --hard to discard. The stash is not popped on the
|
||||
# success path either, so the reset leaves the working tree exactly
|
||||
# where a successful pull would have. Say "commit", not "changes".
|
||||
reset = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'reset', '--hard', previous_sha],
|
||||
capture_output=True, text=True, timeout=60, check=False)
|
||||
if reset.returncode != 0:
|
||||
self.logger.error(
|
||||
"CRITICAL: could not roll %s back to commit %s: %s. It is left "
|
||||
"on a version this core cannot run; "
|
||||
"`git -C %s reset --hard %s` restores it.",
|
||||
plugin_id, previous_sha[:7],
|
||||
(reset.stderr or reset.stdout or '').strip(),
|
||||
plugin_path, previous_sha)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Rolled %s back to commit %s; it stays on the version it was "
|
||||
"already running.", plugin_id, previous_sha[:7])
|
||||
return False
|
||||
|
||||
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
|
||||
"""Replace an installed plugin with a fresh install, atomically.
|
||||
|
||||
@@ -2860,6 +2939,8 @@ class PluginStoreManager:
|
||||
status_result = type('obj', (object,), {'stdout': '', 'stderr': 'Status check timed out'})()
|
||||
|
||||
stash_info = ""
|
||||
# Whether the pull can be undone without destroying work.
|
||||
tree_is_recoverable = not has_changes
|
||||
if has_changes:
|
||||
self.logger.info(f"Stashing local changes in {plugin_id} before update")
|
||||
try:
|
||||
@@ -2873,12 +2954,37 @@ class PluginStoreManager:
|
||||
)
|
||||
if stash_result.returncode == 0:
|
||||
stash_info = " (local changes were stashed)"
|
||||
tree_is_recoverable = True
|
||||
self.logger.info(f"Stashed local changes (including untracked files) for {plugin_id}")
|
||||
else:
|
||||
self.logger.warning(f"Failed to stash local changes for {plugin_id}: {stash_result.stderr}")
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(f"Stash operation timed out for {plugin_id}, proceeding with pull")
|
||||
|
||||
# Do not pull what cannot be un-pulled.
|
||||
#
|
||||
# The compatibility gate below can refuse the commit this
|
||||
# pull brings down, and its only way back is `git reset
|
||||
# --hard`, which discards uncommitted tracked edits. Those
|
||||
# edits are exactly what the stash above exists to protect,
|
||||
# so a stash that failed or timed out leaves the rollback
|
||||
# unable to run without destroying them.
|
||||
#
|
||||
# A pull does not necessarily refuse on a dirty tree -- git
|
||||
# merges happily as long as the incoming commit touches
|
||||
# different files -- so without this the update would
|
||||
# succeed, the gate would refuse, and the reset would take
|
||||
# the user's work with it. Refusing here costs an update in
|
||||
# a case that already went wrong; the alternative costs
|
||||
# data.
|
||||
if not tree_is_recoverable:
|
||||
self.logger.error(
|
||||
"Refusing to update %s: it has local changes that could "
|
||||
"not be stashed, and an incompatible update could then "
|
||||
"only be rolled back by discarding them. Commit or stash "
|
||||
"them by hand, then update.", plugin_id)
|
||||
return False
|
||||
|
||||
# Pull from the determined remote branch
|
||||
self.logger.info(f"Pulling from origin/{remote_pull_branch} for {plugin_id}...")
|
||||
pull_result = subprocess.run(
|
||||
@@ -2901,6 +3007,14 @@ class PluginStoreManager:
|
||||
elif updated_sha:
|
||||
self.logger.info(f"Plugin {plugin_id} updated to commit {updated_sha[:7]}{stash_info}")
|
||||
|
||||
# The install gate, at the only point on this path where
|
||||
# it can be answered. Every other route in goes through
|
||||
# install_plugin, which gates in _install_plugin_impl; this
|
||||
# one did not, so a pull could deliver a manifest flooring
|
||||
# above this core and nothing would notice.
|
||||
if not self._gate_pulled_commit(plugin_id, plugin_path, local_sha):
|
||||
return False
|
||||
|
||||
self._install_dependencies(plugin_path)
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user