diff --git a/cli.py b/cli.py index 00937e9f9a..76084870b5 100644 --- a/cli.py +++ b/cli.py @@ -77,7 +77,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧ # User-managed env files should override stale shell exports on restart. from hermes_constants import get_hermes_home, display_hermes_home from hermes_cli.env_loader import load_hermes_dotenv -from utils import base_url_host_matches +from utils import base_url_host_matches, coerce_bool _hermes_home = get_hermes_home() _project_env = Path(__file__).parent / '.env' @@ -974,7 +974,7 @@ def _run_state_db_auto_maintenance(session_db) -> None: session_db.maybe_auto_prune_and_vacuum( retention_days=int(cfg.get("retention_days", 90)), min_interval_hours=int(cfg.get("min_interval_hours", 24)), - vacuum=bool(cfg.get("vacuum_after_prune", True)), + vacuum=coerce_bool(cfg.get("vacuum_after_prune", True), default=True), ) except Exception as exc: logger.debug("state.db auto-maintenance skipped: %s", exc) diff --git a/gateway/config.py b/gateway/config.py index 5097372791..5c7fa67626 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,23 +17,14 @@ from typing import Dict, List, Optional, Any from enum import Enum from hermes_cli.config import get_hermes_home -from utils import is_truthy_value +from utils import coerce_bool logger = logging.getLogger(__name__) def _coerce_bool(value: Any, default: bool = True) -> bool: """Coerce bool-ish config values, preserving a caller-provided default.""" - if value is None: - return default - if isinstance(value, str): - lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "on"): - return True - if lowered in ("false", "0", "no", "off"): - return False - return default - return is_truthy_value(value, default=default) + return coerce_bool(value, default=default) def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: diff --git a/gateway/run.py b/gateway/run.py index 14bd3ff0d2..629fec239d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -89,7 +89,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # Resolve Hermes home directory (respects HERMES_HOME override) from hermes_constants import get_hermes_home -from utils import atomic_yaml_write, base_url_host_matches, is_truthy_value +from utils import atomic_yaml_write, base_url_host_matches, coerce_bool, is_truthy_value _hermes_home = get_hermes_home() # Load environment variables from ~/.hermes/.env first. @@ -748,7 +748,7 @@ class GatewayRunner: self._session_db.maybe_auto_prune_and_vacuum( retention_days=int(_sess_cfg.get("retention_days", 90)), min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)), - vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)), + vacuum=coerce_bool(_sess_cfg.get("vacuum_after_prune", True), default=True), ) except Exception as exc: logger.debug("state.db auto-maintenance skipped: %s", exc) diff --git a/tests/cli/test_state_db_auto_maintenance.py b/tests/cli/test_state_db_auto_maintenance.py new file mode 100644 index 0000000000..59c5d4fd3f --- /dev/null +++ b/tests/cli/test_state_db_auto_maintenance.py @@ -0,0 +1,31 @@ +from unittest.mock import Mock + +import pytest + + +@pytest.mark.parametrize("raw_value", ["false", False]) +def test_run_state_db_auto_maintenance_respects_vacuum_flag(monkeypatch, tmp_path, raw_value): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + import cli as cli_mod + + session_db = Mock() + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "sessions": { + "auto_prune": True, + "retention_days": 30, + "min_interval_hours": 12, + "vacuum_after_prune": raw_value, + } + }, + ) + + cli_mod._run_state_db_auto_maintenance(session_db) + + session_db.maybe_auto_prune_and_vacuum.assert_called_once_with( + retention_days=30, + min_interval_hours=12, + vacuum=False, + ) diff --git a/tests/gateway/test_state_db_auto_maintenance.py b/tests/gateway/test_state_db_auto_maintenance.py new file mode 100644 index 0000000000..00d94b94b4 --- /dev/null +++ b/tests/gateway/test_state_db_auto_maintenance.py @@ -0,0 +1,34 @@ +from unittest.mock import Mock + +import pytest + +from gateway.config import GatewayConfig +from gateway.run import GatewayRunner + + +@pytest.mark.parametrize("raw_value", ["false", False]) +def test_gateway_runner_respects_vacuum_after_prune_flag(monkeypatch, tmp_path, raw_value): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + fake_db = Mock() + monkeypatch.setattr("hermes_state.SessionDB", lambda: fake_db) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: { + "sessions": { + "auto_prune": True, + "retention_days": 45, + "min_interval_hours": 6, + "vacuum_after_prune": raw_value, + } + }, + ) + + runner = GatewayRunner(GatewayConfig(sessions_dir=tmp_path / "sessions")) + + assert runner._session_db is fake_db + fake_db.maybe_auto_prune_and_vacuum.assert_called_once_with( + retention_days=45, + min_interval_hours=6, + vacuum=False, + ) diff --git a/utils.py b/utils.py index f3d38006d1..3999abeabd 100644 --- a/utils.py +++ b/utils.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) +FALSY_STRINGS = frozenset({"0", "false", "no", "off"}) def is_truthy_value(value: Any, default: bool = False) -> bool: @@ -28,6 +29,28 @@ def is_truthy_value(value: Any, default: bool = False) -> bool: return bool(value) +def coerce_bool(value: Any, default: bool = False) -> bool: + """Coerce bool-ish config values while preserving the caller's default. + + Unlike ``bool(value)``, this treats quoted config strings like + ``"false"`` and ``"0"`` as ``False`` instead of truthy non-empty + strings. Unrecognized strings fall back to ``default`` so malformed + YAML values don't silently flip behavior. + """ + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in TRUTHY_STRINGS: + return True + if lowered in FALSY_STRINGS: + return False + return default + return bool(value) + + def env_var_enabled(name: str, default: str = "") -> bool: """Return True when an environment variable is set to a truthy value.""" return is_truthy_value(os.getenv(name, default), default=False)