fix: unify version comparison, refuse secret-leaking saves, reset skin strikes on card success

Fixes the three suspected bugs this PR's characterization tests pinned,
flipping those tests to assert the corrected behavior:

- plugins/store: ONE shared update comparator. New
  compatibility.is_update_available() (PEP 440 via packaging) is now used
  by both the web UI's update badge (api_v3._is_plugin_update_available
  is a thin alias) and store_manager.update_plugin's reinstall decision.
  Previously update_plugin used raw string equality: 'v1.2.0' vs '1.2.0'
  triggered a full reinstall the UI called unnecessary, and a locally-
  ahead plugin (2.0.0 installed, registry 1.9.0) was silently DOWNGRADED.
  Now equivalent spellings skip the reinstall and locally-ahead versions
  are never downgraded; unparseable versions still reconcile by
  reinstalling from the registry.

- config: save_config and save_config_atomic now refuse (ConfigError)
  when config_secrets.json exists but cannot be loaded. Both previously
  proceeded without stripping, writing the merged secrets into
  config.json in plaintext. The shared _load_secrets_for_save() helper
  raises with an actionable message instead; a missing secrets file is
  still fine (nothing to strip), and _migrate_config's catch-all keeps
  boot resilient.

- skins: render_skin_card resets _skin_failures on both success paths
  (vegas card returned, or mode renderer handled), mirroring
  _render_game. Transient card failures no longer accumulate across a
  session until they permanently disable a working skin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
Claude
2026-08-07 00:44:33 +00:00
parent dc643b4369
commit 206eca078e
8 changed files with 200 additions and 124 deletions
+5
View File
@@ -383,10 +383,15 @@ class SportsCore(ABC):
ctx = skin_runtime.build_context(self, game, size=size) ctx = skin_runtime.build_context(self, game, size=size)
card = skin.render_vegas_card(ctx, dict(game)) card = skin.render_vegas_card(ctx, dict(game))
if card is not None: if card is not None:
# A successful render clears accumulated strikes, mirroring
# _render_game — transient failures must not add up across
# the session and disable a working skin.
self._skin_failures = 0
return card return card
ctx = skin_runtime.build_context(self, game, size=size) ctx = skin_runtime.build_context(self, game, size=size)
render = getattr(skin, f"render_{self.SKIN_MODE}") render = getattr(skin, f"render_{self.SKIN_MODE}")
if render(ctx, dict(game)): if render(ctx, dict(game)):
self._skin_failures = 0
return ctx.canvas return ctx.canvas
except Exception: except Exception:
# Card failures count toward the same 3-strike session disable # Card failures count toward the same 3-strike session disable
+34 -18
View File
@@ -106,14 +106,9 @@ class ConfigManager:
Returns: Returns:
SaveResult with status and details SaveResult with status and details
""" """
# Load current secrets to preserve them # Load current secrets to preserve them (raises if unreadable — see
secrets_content = {} # _load_secrets_for_save)
if os.path.exists(self.secrets_path): secrets_content = self._load_secrets_for_save()
try:
with open(self.secrets_path, 'r') as f_secrets:
secrets_content = json.load(f_secrets)
except Exception as e:
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
# Strip secrets from main config before saving # Strip secrets from main config before saving
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content) config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
@@ -290,19 +285,40 @@ class ConfigManager:
result[key] = value result[key] = value
return result return result
def save_config(self, new_config_data: Dict[str, Any]) -> None: def _load_secrets_for_save(self) -> Dict[str, Any]:
"""Save configuration to the main JSON file, stripping out secrets.""" """Load config_secrets.json for stripping before a save.
secrets_content = {}
if os.path.exists(self.secrets_path): A missing secrets file is fine (nothing to strip). But a file that
EXISTS and cannot be read or parsed means stripping is impossible
and the in-memory config being saved has secrets deep-merged into it,
so proceeding would write them into config.json in plaintext. That
was the historical behavior; it is now a hard refusal. The save
raises so the caller (and user) fixes the secrets file instead of
silently leaking its contents into the world-readable main config.
"""
if not os.path.exists(self.secrets_path):
return {}
try: try:
with open(self.secrets_path, 'r') as f_secrets: with open(self.secrets_path, 'r') as f_secrets:
secrets_content = json.load(f_secrets) return json.load(f_secrets)
except Exception as e: except Exception as e:
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}") error_msg = (
# Continue without stripping if secrets can't be loaded, or handle as critical error f"Refusing to save config: secrets file {self.secrets_path} exists "
# For now, we'll proceed cautiously and save the full new_config_data if secrets are unreadable f"but could not be loaded ({e}). Saving without it would write "
# to prevent accidental data loss if the secrets file is temporarily corrupt. f"merged secret values into config.json in plaintext. Fix or "
# A more robust approach might be to fail the save or use a cached version of secrets. f"remove the secrets file, then retry."
)
self.logger.error(error_msg)
raise ConfigError(error_msg, config_path=self.secrets_path) from e
def save_config(self, new_config_data: Dict[str, Any]) -> None:
"""Save configuration to the main JSON file, stripping out secrets.
Raises ConfigError when the secrets file exists but cannot be loaded,
because stripping would be impossible and secrets would leak into
config.json.
"""
secrets_content = self._load_secrets_for_save()
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content) config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
+35
View File
@@ -180,6 +180,41 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
return None return None
def is_update_available(installed_version: str, latest_version: str) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
THE shared comparator for "should this plugin be updated?" used by both
the web UI's update badge (`api_v3._is_plugin_update_available`) and the
store's `update_plugin` reinstall decision, so the two can never disagree.
Uses PEP 440-aware comparison (``packaging``), which also normalizes
equivalent spellings: ``v1.2.0`` == ``1.2.0`` and ``1.2`` == ``1.2.0``, so
cosmetic differences never trigger a reinstall and a locally modified
plugin whose version is *ahead* of the registry is never "updated"
(downgraded). If either version string can't be parsed the mismatch is
surfaced (True) so the user can reconcile, rather than silently hiding a
potential update.
"""
if not installed_version or not latest_version:
return False
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]: def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
"""Return ``(compatible, reason)``. """Return ``(compatible, reason)``.
+10 -3
View File
@@ -2969,7 +2969,10 @@ class PluginStoreManager:
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch') remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
# Compare local manifest version against registry latest_version # Compare local manifest version against registry latest_version
# to avoid unnecessary reinstalls for monorepo plugins # to avoid unnecessary reinstalls for monorepo plugins. Uses the
# same semantic comparator as the web UI's update badge, so
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
# reinstall and a locally-ahead version is never downgraded.
try: try:
local_manifest_path = plugin_path / "manifest.json" local_manifest_path = plugin_path / "manifest.json"
if local_manifest_path.exists(): if local_manifest_path.exists():
@@ -2977,8 +2980,12 @@ class PluginStoreManager:
local_manifest = json.load(f) local_manifest = json.load(f)
local_version = local_manifest.get('version', '') local_version = local_manifest.get('version', '')
remote_version = plugin_info_remote.get('latest_version', '') remote_version = plugin_info_remote.get('latest_version', '')
if local_version and remote_version and local_version == remote_version: from src.plugin_system.compatibility import is_update_available
self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}") if (local_version and remote_version
and not is_update_available(local_version, remote_version)):
self.logger.info(
f"Plugin {plugin_id} already at latest version "
f"(installed {local_version}, registry {remote_version})")
return True return True
except Exception as e: except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}") self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
+28 -10
View File
@@ -4,8 +4,9 @@ Tests for the ConfigManager secrets round-trip and the load_config fast path.
The contract under test: config_secrets.json values are deep-merged INTO the The contract under test: config_secrets.json values are deep-merged INTO the
in-memory config at load time, and stripped back OUT before anything is in-memory config at load time, and stripped back OUT before anything is
written to config.json so secrets live in exactly one file on disk. This written to config.json so secrets live in exactly one file on disk. This
suite pins that round-trip plus its known sharp edges (some marked as suite pins that round-trip plus its sharp edges, including the guard that a
SUSPECTED BUG and characterized rather than fixed). save REFUSES (ConfigError) when the secrets file exists but can't be loaded,
rather than leaking merged secrets into config.json in plaintext.
Complements test_config_manager.py, which covers loading/migration/validation. Complements test_config_manager.py, which covers loading/migration/validation.
""" """
@@ -16,6 +17,7 @@ import os
import pytest import pytest
from src.config_manager import ConfigManager from src.config_manager import ConfigManager
from src.exceptions import ConfigError
def make_manager(tmp_path, config=None, secrets=None): def make_manager(tmp_path, config=None, secrets=None):
@@ -99,13 +101,11 @@ class TestSaveStripsSecrets:
on_disk = json.loads((tmp_path / "config.json").read_text()) on_disk = json.loads((tmp_path / "config.json").read_text())
assert on_disk == {"timezone": "UTC"} assert on_disk == {"timezone": "UTC"}
def test_corrupt_secrets_file_writes_secrets_to_config_json(self, tmp_path): def test_corrupt_secrets_file_refuses_save_no_plaintext_leak(self, tmp_path):
# SUSPECTED BUG (characterized, not fixed): when the secrets file is # Regression guard: when the secrets file exists but is corrupt at
# corrupt (or otherwise unloadable) at save time, save_config proceeds without # save time, stripping is impossible — the save must raise instead of
# stripping — writing the merged secrets into config.json in # writing the merged secrets into config.json in plaintext (the
# plaintext. The code comments acknowledge the tradeoff (it prevents # historical behavior).
# data loss); this test pins the behavior so any future change to it
# is deliberate.
manager = make_manager( manager = make_manager(
tmp_path, tmp_path,
config={"weather": {"city": "Austin"}}, config={"weather": {"city": "Austin"}},
@@ -114,10 +114,28 @@ class TestSaveStripsSecrets:
loaded = manager.load_config() loaded = manager.load_config()
(tmp_path / "config_secrets.json").write_text("{corrupt") (tmp_path / "config_secrets.json").write_text("{corrupt")
with pytest.raises(ConfigError):
manager.save_config(loaded) manager.save_config(loaded)
# On-disk config untouched: no secret leaked.
on_disk = json.loads((tmp_path / "config.json").read_text()) on_disk = json.loads((tmp_path / "config.json").read_text())
assert on_disk["weather"].get("api_key") == "s3cret" # leaked assert "api_key" not in on_disk.get("weather", {})
def test_corrupt_secrets_file_refuses_atomic_save_too(self, tmp_path):
# Same refusal on the atomic save path, which shared the leak.
manager = make_manager(
tmp_path,
config={"weather": {"city": "Austin"}},
secrets={"weather": {"api_key": "s3cret"}},
)
loaded = manager.load_config()
(tmp_path / "config_secrets.json").write_text("{corrupt")
with pytest.raises(ConfigError):
manager.save_config_atomic(loaded)
on_disk = json.loads((tmp_path / "config.json").read_text())
assert "api_key" not in on_disk.get("weather", {})
class TestLoadFastPath: class TestLoadFastPath:
+25 -15
View File
@@ -455,10 +455,8 @@ class TestExampleSkin:
class TestRenderSkinCard: class TestRenderSkinCard:
"""render_skin_card (vegas cards) shares _render_game's 3-strike counter. """render_skin_card (vegas cards) shares _render_game's 3-strike counter.
The asymmetry pinned here: _render_game resets the counter on success, Both paths reset the counter on success transient failures must not
render_skin_card does NOT card successes never clear strikes, so accumulate across a session and disable a working skin.
failures accumulated across card renders (however far apart) still
disable the skin for the session.
""" """
def _probe(self, skin): def _probe(self, skin):
@@ -518,11 +516,10 @@ class TestRenderSkinCard:
assert probe.builtin_calls == 1 assert probe.builtin_calls == 1
assert BrokenCardSkin.calls == 3 # not consulted again assert BrokenCardSkin.calls == 3 # not consulted again
def test_card_success_does_not_reset_strikes(self): def test_card_success_resets_strikes(self):
"""Characterized asymmetry: unlike _render_game (which resets the """A successful card render clears accumulated strikes (mirroring
counter on success, core.py _render_game), a successful card render _render_game) 2 failures + a success + 1 failure leaves the skin
leaves accumulated strikes in place 2 failures + N successes + 1 enabled with a single strike, instead of disabling it."""
failure still disables the skin."""
card_img = Image.new("RGB", (96, 32), (0, 0, 255)) card_img = Image.new("RGB", (96, 32), (0, 0, 255))
class FlakyCardSkin(ScoreboardSkin): class FlakyCardSkin(ScoreboardSkin):
@@ -540,17 +537,30 @@ class TestRenderSkinCard:
assert probe._skin_failures == 2 assert probe._skin_failures == 2
FlakyCardSkin.fail = False FlakyCardSkin.fail = False
for _ in range(10):
assert probe.render_skin_card({}, (96, 32)) is card_img assert probe.render_skin_card({}, (96, 32)) is card_img
assert probe._skin_failures == 2 # successes did NOT clear strikes assert probe._skin_failures == 0 # success cleared the strikes
FlakyCardSkin.fail = True FlakyCardSkin.fail = True
probe.render_skin_card({}, (96, 32)) probe.render_skin_card({}, (96, 32))
assert probe._skin_failures == 3 assert probe._skin_failures == 1 # counting from the reset state
assert probe.render_skin_card({}, (96, 32)) is None # disabled FlakyCardSkin.fail = False
assert probe.render_skin_card({}, (96, 32)) is card_img # still enabled
def test_render_game_success_does_reset_strikes(self): def test_card_success_via_mode_renderer_also_resets_strikes(self):
"""The other half of the asymmetry, for contrast with the above.""" """The fallthrough path (render_vegas_card None -> mode renderer
True) resets the counter as well."""
class ModeOnlySkin(ScoreboardSkin):
def render_live(self, ctx, game):
ctx.draw.rectangle([0, 0, 5, 5], fill=(255, 0, 0))
return True
probe = self._probe(ModeOnlySkin({}, {}))
probe._skin_failures = 2
assert probe.render_skin_card({}, (96, 32)) is not None
assert probe._skin_failures == 0
def test_render_game_success_also_resets_strikes(self):
"""Same reset contract on the display path, for symmetry."""
class GoodSkin(ScoreboardSkin): class GoodSkin(ScoreboardSkin):
def render_live(self, ctx, game): def render_live(self, ctx, game):
return True return True
+51 -51
View File
@@ -1,25 +1,17 @@
""" """
Drift guard: the repo has FOUR version-comparison implementations, and they Drift guard for version comparison.
do not agree. This file pins each one's answer on the same inputs so any
future change to one of them (or a fifth copy appearing) surfaces here.
The four: There is now ONE shared "should this plugin update?" comparator
1. src/plugin_system/compatibility.py parse_semver / tuple comparison `src.plugin_system.compatibility.is_update_available` used by both the web
(install gate). UI's update badge (`api_v3._is_plugin_update_available`) and the store's
2. web_interface/blueprints/api_v3.py _is_plugin_update_available, uses `update_plugin` reinstall decision, so the badge and the actual reinstall
packaging.version (update badge in the UI). can never disagree. (Historically the store used raw string equality, which
3. src/plugin_system/store_manager.py update_plugin's raw STRING EQUALITY reinstalled over cosmetic differences like "v1.2.0" vs "1.2.0" and even
for monorepo plugins ("local_version == remote_version"). DOWNGRADED locally-ahead plugins; this file's tests killed that.)
4. src/skin_system/skin_runtime.py _major, int(major) gate for the
skin API.
SUSPECTED BUG (characterized here, not fixed): #3 disagrees with #2. For Two other version parsers legitimately remain and are pinned here so they
"v1.2.0" vs "1.2.0" the UI says "no update available" while update_plugin don't drift: `compatibility.parse_semver` (the install-compatibility gate,
performs a full reinstall; for a locally-ahead plugin ("2.0.0" installed, range-spec oriented) and `skin_runtime._major` (skin API major gate).
registry "1.9.0") the UI says no update but update_plugin DOWNGRADES via
reinstall. Unifying on one comparator is tracked follow-up work; when that
lands, the expectations in TestStoreManagerStringEquality flip and this
file is the reminder to update them deliberately.
""" """
import json import json
@@ -28,44 +20,40 @@ from unittest.mock import patch
import pytest import pytest
from packaging.version import parse as pkg_parse from packaging.version import parse as pkg_parse
from src.plugin_system.compatibility import parse_semver from src.plugin_system.compatibility import is_update_available, parse_semver
from src.skin_system.skin_runtime import _major from src.skin_system.skin_runtime import _major
from src.plugin_system.store_manager import PluginStoreManager from src.plugin_system.store_manager import PluginStoreManager
from web_interface.blueprints.api_v3 import _is_plugin_update_available from web_interface.blueprints.api_v3 import _is_plugin_update_available
# (installed, registry) pairs and what each comparator concludes. # (installed, registry) -> update available?
CASES = [ CASES = [
# pair parse_semver equal? api_v3 update? store equal-string? (("1.2.0", "1.2.0"), False), # identical
(("1.2.0", "1.2.0"), True, False, True), (("v1.2.0", "1.2.0"), False), # cosmetic v-prefix, semantically equal
(("v1.2.0", "1.2.0"), True, False, False), (("1.2", "1.2.0"), False), # short form, semantically equal
(("1.2", "1.2.0"), True, False, False), (("1.2.0", "1.2.0-rc1"), False), # rc of same release is not newer
(("1.2.0", "1.2.0-rc1"), True, False, False), (("1.2.0", "1.3.0"), True), # registry genuinely newer
(("1.2.0", "1.3.0"), False, True, False), (("2.0.0", "1.9.0"), False), # locally ahead — never downgrade
(("2.0.0", "1.9.0"), False, False, False), (("abc.def", "1.0.0"), True), # unparseable — surface the mismatch
(("", "1.0.0"), False), # missing either side — nothing to do
(("1.0.0", ""), False),
] ]
class TestComparatorMatrix: class TestSharedComparator:
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES) @pytest.mark.parametrize("pair,expected", CASES)
def test_parse_semver_equality(self, pair, semver_equal, api_update, store_equal): def test_is_update_available(self, pair, expected):
a, b = pair
assert (parse_semver(a) == parse_semver(b)) is semver_equal
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES)
def test_api_v3_update_available(self, pair, semver_equal, api_update, store_equal):
installed, latest = pair installed, latest = pair
assert _is_plugin_update_available(installed, latest) is api_update assert is_update_available(installed, latest) is expected
@pytest.mark.parametrize("pair,semver_equal,api_update,store_equal", CASES) @pytest.mark.parametrize("pair,expected", CASES)
def test_store_manager_string_equality(self, pair, semver_equal, api_update, store_equal): def test_api_v3_helper_agrees(self, pair, expected):
# The literal comparison update_plugin performs at its # The UI badge helper must be a pure alias of the shared comparator.
# "already at latest version" check. installed, latest = pair
a, b = pair assert _is_plugin_update_available(installed, latest) is expected
assert (a == b) is store_equal
class TestStoreManagerStringEquality: class TestStoreManagerUsesSharedComparator:
"""Drive update_plugin's real code path to its version check.""" """Drive update_plugin's real code path to its version check."""
def _store(self, tmp_path, local_version, registry_version): def _store(self, tmp_path, local_version, registry_version):
@@ -98,18 +86,30 @@ class TestStoreManagerStringEquality:
assert result is True assert result is True
reinstall.assert_not_called() reinstall.assert_not_called()
def test_v_prefix_triggers_reinstall_despite_semantic_equality(self, tmp_path): def test_v_prefix_equivalent_skips_reinstall(self, tmp_path):
# SUSPECTED BUG: packaging (and api_v3) treat these as equal; the # "v1.2.0" == "1.2.0" semantically — no pointless reinstall.
# string comparison does not, so the user gets a full reinstall.
store, info = self._store(tmp_path, "v1.2.0", "1.2.0") store, info = self._store(tmp_path, "v1.2.0", "1.2.0")
result, reinstall = self._run_update(store, info) result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_locally_ahead_version_is_never_downgraded(self, tmp_path):
# A plugin ahead of the registry (local dev build) must not be
# "updated" — that would be a downgrade.
store, info = self._store(tmp_path, "2.0.0", "1.9.0")
result, reinstall = self._run_update(store, info)
assert result is True
reinstall.assert_not_called()
def test_registry_newer_triggers_reinstall(self, tmp_path):
store, info = self._store(tmp_path, "1.2.0", "1.3.0")
result, reinstall = self._run_update(store, info)
reinstall.assert_called_once() reinstall.assert_called_once()
assert result is True assert result is True
def test_locally_ahead_version_triggers_downgrade_reinstall(self, tmp_path): def test_unparseable_version_surfaces_via_reinstall(self, tmp_path):
# SUSPECTED BUG: a plugin ahead of the registry (local dev build) is # Direction unknowable → reconcile by reinstalling from the registry.
# "updated" — i.e. downgraded — because inequality is the only test. store, info = self._store(tmp_path, "abc.def", "1.0.0")
store, info = self._store(tmp_path, "2.0.0", "1.9.0")
result, reinstall = self._run_update(store, info) result, reinstall = self._run_update(store, info)
reinstall.assert_called_once() reinstall.assert_called_once()
assert result is True assert result is True
+6 -21
View File
@@ -124,28 +124,13 @@ def _is_plugin_update_available(installed_version: str, latest_version: str) ->
"""Return True when the registry's ``latest_version`` is strictly newer """Return True when the registry's ``latest_version`` is strictly newer
than the installed version. than the installed version.
Uses PEP 440 / semver-aware comparison so a locally modified plugin whose Thin alias for the shared comparator in
version is *ahead* of the published registry is not flagged as needing an `src.plugin_system.compatibility.is_update_available` the store's
update. If either version string can't be parsed, falls back to a plain `update_plugin` uses the same function, so the UI badge and the actual
inequality check (any difference is surfaced so the user can reconcile). reinstall decision can never disagree.
""" """
if not installed_version or not latest_version: from src.plugin_system.compatibility import is_update_available
return False return is_update_available(installed_version, latest_version)
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def _ensure_cache_manager(): def _ensure_cache_manager():
"""Ensure cache manager is initialized.""" """Ensure cache manager is initialized."""