diff --git a/agent/turn_explainers.py b/agent/turn_explainers.py index 945b7e7660..4e66025ffa 100644 --- a/agent/turn_explainers.py +++ b/agent/turn_explainers.py @@ -123,7 +123,7 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "run `sqlite3 ... \".recover\"` against the live " "state.db, a vulnerable sqlite3 CLI can corrupt it " "further\n" - "3. Restore from a backup in ~/.hermes/backups/\n" + "3. Restore from a backup in {backups_dir}/\n" "Then send your message again." ), "disk": ( @@ -295,7 +295,11 @@ class TurnExplainersMixin: ) if persistence_cause == "corrupt": # Copy-pasteable, so name the real store (profiles / HERMES_HOME do not live under ~/.hermes). + from hermes_constants import get_default_hermes_root from hermes_state import _default_db_path body = body.replace("{db_path}", str(_default_db_path())) + body = body.replace( + "{backups_dir}", str(get_default_hermes_root() / "backups") + ) return _NO_REPLY + body if body else "" diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index c5ce3cb4b2..194dcd4718 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -722,10 +722,12 @@ class GatewayNotificationsMixin: error = getattr(self, "_session_db_init_error", None) if not error: return + from hermes_constants import get_default_hermes_root from hermes_state import _default_db_path, classify_persistence_error, format_session_db_unavailable if classify_persistence_error(error) == "corrupt": # Copy-pasteable, so name the real store (profiles / HERMES_HOME do not live under ~/.hermes). db_path = _default_db_path() + backups_dir = get_default_hermes_root() / "backups" message = ( "⚠️ Session database corruption detected. Messages may not be " "persisted. Recovery options:\n" @@ -738,7 +740,7 @@ class GatewayNotificationsMixin: " — recovery snapshots the damaged file first; do NOT run " "`sqlite3 ... \".recover\"` against the live state.db, a " "vulnerable sqlite3 CLI can corrupt it further\n" - "3. Restore from a backup in ~/.hermes/backups/\n" + f"3. Restore from a backup in {backups_dir}/\n" "Run `hermes doctor` for sanitized diagnostics." ) else: diff --git a/tests/run_agent/test_corruption_recovery_guidance.py b/tests/run_agent/test_corruption_recovery_guidance.py index 155d326e20..a583544fa8 100644 --- a/tests/run_agent/test_corruption_recovery_guidance.py +++ b/tests/run_agent/test_corruption_recovery_guidance.py @@ -31,6 +31,38 @@ def test_format_turn_completion_corrupt_includes_recovery_options(): assert "Freeing disk space will not help" in explanation +def test_gateway_corruption_banner_backups_dir_follows_hermes_home(monkeypatch, tmp_path): + """The gateway broadcast's step 3 must name the live backups dir, not ~/.hermes (#104250). + + Pre-update backups live at ``/backups`` (``hermes_cli/backup.py``); a + custom-HERMES_HOME gateway must not be told to restore from a directory that never + held its backups. + """ + import asyncio + + import gateway.run as gateway_run + + custom_home = tmp_path / "custom-hermes-home" + monkeypatch.setenv("HERMES_HOME", str(custom_home)) + + runner = object.__new__(gateway_run.GatewayRunner) + runner._session_db_init_error = "database disk image is malformed" + sent = [] + monkeypatch.setattr( + runner, "_home_channel_transports", lambda: [("telegram", {}, "home-chat", object())] + ) + + async def _capture_send(_platform, _home, _transport, message, _log_fmt): + sent.append(message) + + monkeypatch.setattr(runner, "_send_home_channel_message", _capture_send) + asyncio.run(runner._send_session_db_warning_notifications()) + + assert sent, "warning must be broadcast to home channels" + assert f"{custom_home / 'backups'}" in sent[0] + assert "~/.hermes/backups" not in sent[0] + + def test_format_turn_completion_corrupt_never_names_the_live_db(): """The 'corrupt' cause must not direct a raw sqlite3 shell at the live DB. diff --git a/tests/run_agent/test_turn_completion_explainer.py b/tests/run_agent/test_turn_completion_explainer.py index 70ffca0bf7..36ba698b31 100644 --- a/tests/run_agent/test_turn_completion_explainer.py +++ b/tests/run_agent/test_turn_completion_explainer.py @@ -155,6 +155,24 @@ def test_explanation_persistence_corrupt_cause_never_says_free_space(): assert "full disk" not in lower +def test_explanation_persistence_corrupt_backups_dir_follows_hermes_home(monkeypatch, tmp_path): + """Step 3 must name the backups dir under the ACTIVE home, not ~/.hermes (#104250). + + Pre-update backups live at ``/backups`` (``hermes_cli/backup.py``), so a + custom-HERMES_HOME deployment told to restore from ``~/.hermes/backups/`` is misdirected + mid data-loss incident: that directory may not exist at all, or may hold an unrelated + install's backups. + """ + custom_home = tmp_path / "custom-hermes-home" + monkeypatch.setenv("HERMES_HOME", str(custom_home)) + out = AIAgent._format_turn_completion_explanation( + "session_persistence_failed", "corrupt" + ) + assert f"{custom_home / 'backups'}" in out + assert "~/.hermes/backups" not in out + assert "{backups_dir}" not in out + + def test_explanation_persistence_replaced_cause_forbids_inplace_repair(): out = AIAgent._format_turn_completion_explanation( "session_persistence_failed", "replaced"