fix(agent): interpolate the live backups dir into corruption recovery guidance

The corrupt-cause recovery guidance hardcoded `~/.hermes/backups/` while
every other path in the same message follows the active HERMES_HOME
(`{db_path}` is already interpolated). A custom-home or named-profile
deployment was told to restore from a directory that may not exist at all,
mid data-loss incident. Both sites (turn-completion explainer and gateway
startup broadcast) now interpolate `<hermes_root>/backups` via
get_default_hermes_root(), matching hermes_cli/backup.py's real backup
location.

Fixes #104250
This commit is contained in:
liuhao1024
2026-09-06 21:56:33 +08:00
committed by Teknium
parent 57c60f2e0c
commit 342967058c
4 changed files with 58 additions and 2 deletions
+5 -1
View File
@@ -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 ""
+3 -1
View File
@@ -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:
@@ -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 ``<hermes_root>/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.
@@ -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 ``<hermes_root>/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"