mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
`hermes sessions optimize` could corrupt state.db. Root cause is Hermes, not the SQLite WAL-reset bug (#69784). close() on ANY file descriptor for a SQLite database cancels every POSIX advisory lock the process holds on that file, including a running VACUUM's EXCLUSIVE lock (sqlite.org/howtocorrupt.html section 2.2). Hermes byte-probed live databases in several hot paths: the zeroed-state.db detector runs on every SessionDB construction (and the gateway builds those constantly), and kanban's post-commit invariant check ran after every COMMIT. While VACUUM rewrote the file, those probes dropped its lock and let other processes write into it. A/B against the real code, only variable being the raw read: SQLite 3.50.4, VACUUM + concurrent writers, DELETE mode raw open/close during VACUUM 8 vacuums, 319 vacuum errors, 2/2 corrupt no raw read (control) 229 vacuums, 0 vacuum errors, 0/2 corrupt SQLite 3.53.1 (WAL-reset FIXED) reproduces identically: 2/2 corrupt. After this change: 0/4 corrupt, 0 vacuum errors. Because the upgraded runtime corrupts too, replacing the embedded SQLite does not fix this class; and because DELETE is where it reproduces, #70055's "force DELETE on vulnerable builds" mitigation steered users into the failing mode. That gate is reverted here: vulnerable builds get WAL again and still warn so operators can upgrade. - add hermes_cli/sqlite_safe_read.py: read page_count via PRAGMA over the existing connection instead of open()+seek(28); byte-level probes are restricted to before any connection exists and refused once one is live, with an explicit force= escape for offline artifacts (snapshots, archives) - track live connections in SessionDB and kanban's connect so that guard is enforced rather than merely documented - kanban's torn-extend check now only applies under a rollback journal; in WAL a committed page may still legitimately sit in the -wal file - revert the force-DELETE WAL gate and update the tests that pinned it Regression tests assert the behavioural contract (an external process stays locked out across Hermes' inspection calls) and were verified to fail when the old raw-open behaviour is restored.
205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
"""SQLite WAL-reset vulnerability gate (issue #69784).
|
|
|
|
Hermes must not *enable* multi-process WAL on SQLite builds that still contain
|
|
the upstream WAL-reset corruption bug:
|
|
https://sqlite.org/wal.html#walresetbug
|
|
|
|
Existing on-disk WAL databases are left alone (no live downgrade).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import hermes_state
|
|
from hermes_state import (
|
|
apply_wal_with_fallback,
|
|
is_sqlite_wal_reset_vulnerable,
|
|
sqlite_source_id,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_wal_reset_bug_warnings():
|
|
hermes_state._wal_reset_bug_warned_paths.clear()
|
|
yield
|
|
hermes_state._wal_reset_bug_warned_paths.clear()
|
|
|
|
|
|
class TestIsSqliteWalResetVulnerable:
|
|
@pytest.mark.parametrize(
|
|
"version_info,expected",
|
|
[
|
|
((3, 6, 23), False), # pre-WAL
|
|
((3, 7, 0), True),
|
|
((3, 44, 5), True),
|
|
((3, 44, 6), False), # backport
|
|
((3, 44, 9), False),
|
|
((3, 45, 0), True),
|
|
((3, 46, 1), True),
|
|
((3, 50, 4), True),
|
|
((3, 50, 6), True),
|
|
((3, 50, 7), False), # backport
|
|
((3, 50, 99), False),
|
|
((3, 51, 0), True),
|
|
((3, 51, 2), True),
|
|
((3, 51, 3), False), # fixed line
|
|
((3, 52, 0), False),
|
|
],
|
|
)
|
|
def test_version_matrix(self, version_info, expected):
|
|
assert is_sqlite_wal_reset_vulnerable(version_info) is expected
|
|
|
|
def test_defaults_to_linked_library(self):
|
|
assert isinstance(is_sqlite_wal_reset_vulnerable(), bool)
|
|
|
|
|
|
class TestApplyWalWalResetGate:
|
|
def test_fresh_db_gets_wal_even_when_vulnerable(self, tmp_path, monkeypatch, caplog):
|
|
"""Forcing DELETE on vulnerable SQLite was reverted.
|
|
|
|
Measured against Hermes' own concurrent write paths, DELETE is the
|
|
mode that corrupts: a bare open()/close() on the DB file cancels this
|
|
process's POSIX advisory locks (including a running VACUUM's EXCLUSIVE
|
|
lock) and a rollback journal has no second line of defence. WAL
|
|
survived the same harness. We still warn so the operator can upgrade
|
|
the runtime, but we no longer steer them into the failing mode.
|
|
"""
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
|
)
|
|
conn = sqlite3.connect(str(tmp_path / "fresh.db"))
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
mode = apply_wal_with_fallback(conn, db_label="fresh.db")
|
|
assert mode == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
# The operator is still told to upgrade the runtime...
|
|
assert any("WAL-reset" in r.getMessage() for r in caplog.records)
|
|
# ...but we never announce a downgrade to DELETE.
|
|
assert not any(
|
|
"instead of enabling WAL" in r.getMessage() for r in caplog.records
|
|
)
|
|
conn.close()
|
|
|
|
def test_existing_wal_left_alone_when_vulnerable(
|
|
self, tmp_path, monkeypatch, caplog
|
|
):
|
|
"""Already-WAL DBs must not be live-downgraded under concurrent openers."""
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
|
)
|
|
path = tmp_path / "prior_wal.db"
|
|
seed = sqlite3.connect(str(path))
|
|
try:
|
|
seed.execute("PRAGMA journal_mode=WAL")
|
|
seed.execute("CREATE TABLE t (x INTEGER)")
|
|
seed.execute("INSERT INTO t VALUES (42)")
|
|
seed.commit()
|
|
assert seed.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
finally:
|
|
seed.close()
|
|
|
|
conn = sqlite3.connect(str(path), timeout=30.0)
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
mode = apply_wal_with_fallback(conn, db_label="prior_wal.db")
|
|
assert mode == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
assert conn.execute("SELECT x FROM t").fetchone()[0] == 42
|
|
assert any("WAL-reset" in r.getMessage() for r in caplog.records)
|
|
# Must not attempt a live journal_mode flip.
|
|
assert not any(
|
|
"instead of enabling WAL" in r.getMessage() for r in caplog.records
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_existing_wal_does_not_run_checkpoint_or_delete(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
|
)
|
|
|
|
class _TracingConn(sqlite3.Connection):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, **kw)
|
|
self.executed = []
|
|
|
|
def execute(self, sql, params=()): # type: ignore[override]
|
|
self.executed.append(sql)
|
|
return super().execute(sql, params)
|
|
|
|
path = tmp_path / "trace_wal.db"
|
|
with sqlite3.connect(str(path)) as seed:
|
|
seed.execute("PRAGMA journal_mode=WAL")
|
|
|
|
conn = _TracingConn(str(path))
|
|
try:
|
|
assert apply_wal_with_fallback(conn, db_label="trace_wal.db") == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
joined_lower = "\n".join(conn.executed).lower().replace(" ", "")
|
|
assert "wal_checkpoint" not in joined_lower
|
|
assert "journal_mode=delete" not in joined_lower
|
|
|
|
def test_fixed_sqlite_still_enables_wal(self, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: False
|
|
)
|
|
conn = sqlite3.connect(str(tmp_path / "fixed.db"))
|
|
mode = apply_wal_with_fallback(conn, db_label="fixed.db")
|
|
assert mode == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
conn.close()
|
|
|
|
def test_warning_deduped_per_label(self, tmp_path, monkeypatch, caplog):
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
|
)
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
for name in ("a.db", "a.db", "b.db"):
|
|
conn = sqlite3.connect(str(tmp_path / name))
|
|
apply_wal_with_fallback(conn, db_label=name)
|
|
conn.close()
|
|
warnings = [r for r in caplog.records if "WAL-reset" in r.getMessage()]
|
|
assert len(warnings) == 2
|
|
|
|
|
|
def test_sqlite_source_id_non_empty_string():
|
|
src = sqlite_source_id()
|
|
assert isinstance(src, str)
|
|
assert src
|
|
|
|
|
|
def test_doctor_warns_without_adding_issues(monkeypatch, tmp_path, capsys):
|
|
"""Vulnerable SQLite is warn-only in doctor — not a blocking issues[] entry."""
|
|
from hermes_cli.doctor import run_doctor
|
|
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: home)
|
|
monkeypatch.setattr(
|
|
hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True
|
|
)
|
|
monkeypatch.setattr(hermes_state, "sqlite_source_id", lambda: "testid-abc")
|
|
monkeypatch.setattr(sqlite3, "sqlite_version", "3.50.4", raising=False)
|
|
|
|
args = SimpleNamespace(fix=False, ack=None)
|
|
try:
|
|
run_doctor(args)
|
|
except SystemExit:
|
|
pass
|
|
|
|
out = capsys.readouterr().out
|
|
assert "SQLite" in out
|
|
assert "3.50.4" in out
|
|
assert "WAL-reset" in out
|
|
assert "hermes update" in out
|
|
# No longer appended to the blocking issues summary.
|
|
assert "Linked SQLite is vulnerable" not in out
|