hermes-agent/tests/test_hermes_state_readonly_preflight.py
Teknium 373632e338 fix(state): recover from read-only database files at startup
Port from Kilo-Org/kilocode#12508: a stray read-only state.db / -wal /
-shm (sudo run, restored backup, copied dotfiles) previously killed
SessionDB init with an opaque 'sqlite3.OperationalError: attempt to
write a readonly database' raised from deep inside _init_schema —
naming no file and no fix — and the obvious wrong 'fix' (deleting the
-wal) silently loses committed transactions.

New preflight_db_writability() runs before the first connection on both
DB open paths (SessionDB.__init__ and hermes_cli.kanban_db.connect):

- files inside the Hermes home tree are repaired with chmod u+rw (the
  safe scope: Hermes owns them, and the OS makes chmod fail on files
  the user doesn't own, which bounds the repair exactly);
- anything else (root-owned files, read-only mounts, custom paths)
  fails fast with an error naming the exact file and the exact chmod
  command, plus an explicit 'do NOT delete the -wal' warning;
- WAL sidecars are never deleted or truncated — once writable, the
  normal open path checkpoints committed frames into the DB.

Proven live on main first: chmod 444 state.db -> SessionDB() raises the
opaque readonly error. With the fix: in-home DBs self-heal; out-of-home
DBs get the actionable message. Sabotage run confirms the integration
tests fail without the wiring (2 failed / 10 passed).
2026-07-27 17:27:38 -07:00

214 lines
7.1 KiB
Python

"""Tests for the read-only DB preflight (port of Kilo-Org/kilocode#12508).
A stray read-only ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored
backup, copied dotfiles) used to surface as an opaque
``sqlite3.OperationalError: attempt to write a readonly database`` raised
from deep inside ``_init_schema`` — naming no file and no fix.
``preflight_db_writability`` now runs before the first connection:
- files inside the Hermes home tree are repaired with ``chmod u+rw``
(the safe scope — chmod fails on files the user doesn't own);
- anything else fails fast with an error naming the exact file and the
exact ``chmod`` command;
- WAL sidecars are never deleted, so committed frames survive repair.
"""
import os
import sqlite3
import stat
import sys
from pathlib import Path
import pytest
import hermes_state
from hermes_state import SessionDB, preflight_db_writability
pytestmark = [
pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod semantics"),
pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root bypasses file permission checks",
),
]
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME so the repair scope covers tmp DBs."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _make_db(path: Path) -> None:
conn = sqlite3.connect(str(path))
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
conn.close()
def _make_wal_db(path: Path) -> None:
"""Create a WAL-mode DB with committed-but-uncheckpointed frames."""
conn = sqlite3.connect(str(path))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE t (x)")
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
# Keep a READ-ONLY second connection open so neither close can
# checkpoint: the writer skips checkpoint-on-close because another
# connection exists, and the ro holder cannot checkpoint at all.
# The committed row therefore lives only in the -wal file.
holder = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
holder.execute("SELECT 1").fetchone()
conn.execute("INSERT INTO t VALUES (42)")
conn.commit()
conn.close()
holder.close()
assert path.with_name(path.name + "-wal").is_file(), (
"fixture precondition: -wal sidecar must survive with pending frames"
)
class TestRepairScope:
def test_repairs_readonly_db_inside_home(self, hermes_home):
db = hermes_home / "state.db"
_make_db(db)
os.chmod(db, 0o444)
preflight_db_writability(db, db_label="state.db")
assert os.access(db, os.W_OK)
def test_repairs_readonly_sidecars(self, hermes_home):
db = hermes_home / "state.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
assert wal.is_file(), "fixture must leave a -wal behind"
os.chmod(db, 0o444)
os.chmod(wal, 0o444)
preflight_db_writability(db, db_label="state.db")
assert os.access(db, os.W_OK)
assert os.access(wal, os.W_OK)
def test_wal_data_survives_repair(self, hermes_home):
"""The committed WAL frame must be readable after repair — proof the
preflight never drops/truncates a sidecar."""
db = hermes_home / "state.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
os.chmod(db, 0o444)
os.chmod(wal, 0o444)
preflight_db_writability(db, db_label="state.db")
conn = sqlite3.connect(str(db))
rows = conn.execute("SELECT x FROM t").fetchall()
conn.close()
assert (42,) in rows
def test_repairs_readonly_parent_directory(self, hermes_home):
sub = hermes_home / "kanban"
sub.mkdir()
db = sub / "kanban.db"
_make_db(db)
os.chmod(sub, 0o555)
try:
preflight_db_writability(db, db_label="kanban.db")
assert os.access(sub, os.W_OK)
finally:
os.chmod(sub, 0o755)
class TestRefusalOutsideScope:
def test_actionable_error_names_file_and_chmod(self, hermes_home, tmp_path):
outside = tmp_path / "elsewhere"
outside.mkdir()
db = outside / "custom.db"
_make_db(db)
os.chmod(db, 0o444)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
preflight_db_writability(db, db_label="custom.db")
msg = str(exc_info.value)
assert str(db) in msg
assert "chmod u+rw" in msg
# Must NOT have silently chmod'd a file outside the home tree.
assert not os.access(db, os.W_OK)
finally:
os.chmod(db, 0o644)
def test_wal_error_warns_against_deletion(self, hermes_home, tmp_path):
outside = tmp_path / "elsewhere"
outside.mkdir()
db = outside / "custom.db"
_make_wal_db(db)
wal = db.with_name(db.name + "-wal")
os.chmod(wal, 0o444)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
preflight_db_writability(db, db_label="custom.db")
msg = str(exc_info.value)
assert str(wal) in msg
assert "Do NOT delete" in msg
finally:
os.chmod(wal, 0o644)
class TestSkips:
def test_memory_uri_skipped(self, hermes_home):
preflight_db_writability(Path(":memory:"))
def test_file_uri_skipped(self, hermes_home):
preflight_db_writability(Path("file:whatever?mode=ro"))
def test_missing_files_no_error(self, hermes_home):
preflight_db_writability(hermes_home / "state.db")
def test_healthy_db_untouched(self, hermes_home):
db = hermes_home / "state.db"
_make_db(db)
before = stat.S_IMODE(db.stat().st_mode)
preflight_db_writability(db)
assert stat.S_IMODE(db.stat().st_mode) == before
class TestSessionDBIntegration:
def test_sessiondb_selfheals_readonly_db_in_home(self, hermes_home):
db_path = hermes_home / "state.db"
first = SessionDB(db_path)
first.close()
for suffix in ("", "-wal", "-shm"):
p = db_path.with_name(db_path.name + suffix)
if p.is_file():
os.chmod(p, 0o444)
db = SessionDB(db_path) # must not raise "readonly database"
try:
assert os.access(db_path, os.W_OK)
finally:
db.close()
def test_sessiondb_actionable_error_outside_home(
self, hermes_home, tmp_path
):
outside = tmp_path / "custom-loc"
outside.mkdir()
db_path = outside / "state.db"
first = SessionDB(db_path)
first.close()
os.chmod(db_path, 0o444)
hermes_state._set_last_init_error(None)
try:
with pytest.raises(sqlite3.OperationalError) as exc_info:
SessionDB(db_path)
msg = str(exc_info.value)
assert str(db_path) in msg
assert "chmod" in msg
finally:
os.chmod(db_path, 0o644)
hermes_state._set_last_init_error(None)