Files
hermes-agent/tests/scripts/test_verify_user_state.py
ethernet 498725ae59 test(install-e2e): a migration retiring a key is not user-state loss
An install from v2026.3.12 ships `.env` with `LLM_MODEL` set, because that
release's template wrote it. The upgrade runs config migration 12 -> 13, which
clears that dead var -- and the user-state verifier reported the change as the
upgrade modifying the user's own state, failing the leg.

The verifier exists to catch an upgrade taking state away; a var the CURRENT
tree retires is not that. The retired set is parsed out of
`hermes_cli/config_migrations.py` (the `for dead in (...): save_env_value(dead,
"")` shape) rather than restated here, so it cannot drift from the tree, and an
unreadable source retires nothing -- every .env change stays fatal.

Tolerance is deliberately narrow: only keys the tree retires, only when the
upgrade EMPTIED them, and only when no key was added or removed. Clearing a
live key, or deleting a retired one outright, still fails.
2026-09-18 02:57:38 -04:00

494 lines
20 KiB
Python

"""Unit tests for the user-state upgrade-preservation verifier.
tests/install/e2e-assets/verify-user-state.py is the standalone hook the
install/update E2E drivers call around a real upgrade. These tests exercise it
against a real temp HERMES_HOME (real files, real sqlite databases) — no
source-reading, no filesystem mocks.
The contract under test: an upgrade may ADD state and may rewrite config.yaml
(config migration) and the bundled skills tree (skills sync), but it may not
delete or modify the user's own durable state, and it may not shrink state.db.
plugins/** is deliberately not owned here — verify-plugin-preservation.py owns
it — so this tool must not fail on it.
"""
from __future__ import annotations
import importlib.util
import json
import os
import sqlite3
import subprocess
import sys
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
VERIFIER = os.path.join(_HERE, "..", "install", "e2e-assets", "verify-user-state.py")
_spec = importlib.util.spec_from_file_location("verify_user_state", VERIFIER)
vus = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(vus)
def _make_db(path, sessions=2, messages=3):
con = sqlite3.connect(str(path))
con.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY)")
con.execute("CREATE TABLE IF NOT EXISTS messages (id TEXT)")
con.executemany("INSERT OR REPLACE INTO sessions VALUES (?)",
[(f"s{i}",) for i in range(sessions)])
con.executemany("INSERT INTO messages VALUES (?)",
[(f"m{i}",) for i in range(messages)])
con.commit()
con.close()
def _home(tmp_path):
home = tmp_path / "home"
for rel in ("memories", "cron", "sessions", "skills/foo", "skills/.archive/mine",
"plugins/demo", "photon/sidecar", "profiles/work"):
(home / rel).mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("timezone: utc\n", encoding="utf-8")
(home / ".env").write_text("NOUS_API_KEY=xxx\n", encoding="utf-8")
(home / "auth.json").write_text('{"tokens":{}}\n', encoding="utf-8")
(home / "memories" / "note.md").write_text("remember\n", encoding="utf-8")
(home / "cron" / "jobs.json").write_text('{"jobs":[]}\n', encoding="utf-8")
(home / "sessions" / "2026.jsonl").write_text('{"t":1}\n', encoding="utf-8")
(home / "skills" / "foo" / "SKILL.md").write_text("bundled\n", encoding="utf-8")
(home / "skills" / ".archive" / "mine" / "SKILL.md").write_text("mine\n", encoding="utf-8")
(home / "plugins" / "demo" / "plugin.yaml").write_text("name: demo\n", encoding="utf-8")
(home / "photon" / "sidecar" / "package-lock.json").write_text("{}\n", encoding="utf-8")
(home / "profiles" / "work" / "config.yaml").write_text("x: 1\n", encoding="utf-8")
_make_db(home / "state.db")
return home
def _snapshot(home):
return vus.snapshot_home(str(home))
def _verify(home, snap):
return vus.verify_home(str(home), snap)
# --- shape ------------------------------------------------------------------
def test_snapshot_records_rows_not_bytes_for_state_db(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
record = snap["entries"]["state.db"]
assert record["kind"] == "file"
assert record["rows"] == {"sessions": 2, "messages": 3}
assert "sha256" not in record, "a live db must be judged by rows, not bytes"
def test_snapshot_is_read_only(tmp_path):
home = _home(tmp_path)
before = sorted(os.path.relpath(os.path.join(dp, n), home)
for dp, _, ns in os.walk(home) for n in ns)
snap = _snapshot(home)
after = sorted(os.path.relpath(os.path.join(dp, n), home)
for dp, _, ns in os.walk(home) for n in ns)
assert before == after
assert snap["entries"], "a used home must produce judged entries"
def test_plugins_and_bundled_skills_are_not_judged(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
assert not [k for k in snap["entries"] if k.startswith("plugins/")], \
"plugins/** is owned by verify-plugin-preservation.py"
assert "skills/foo/SKILL.md" in snap["advisory"]
assert "skills/foo/SKILL.md" not in snap["entries"]
assert "skills/.archive/mine/SKILL.md" in snap["entries"], \
"the curator's archive holds USER skills and is judged"
# --- the contract -----------------------------------------------------------
def test_verify_passes_when_nothing_changed(tmp_path):
home = _home(tmp_path)
report = _verify(home, _snapshot(home))
assert report["ok"] is True
assert report["deleted"] == [] and report["modified"] == {}
def test_tolerates_added_files(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / "memories" / "another.md").write_text("new\n", encoding="utf-8")
report = _verify(home, snap)
assert report["ok"] is True, "an upgrade may add files"
assert "memories/another.md" in report["added"]
def test_fails_when_a_user_file_is_deleted(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / "memories" / "note.md").unlink()
report = _verify(home, snap)
assert report["ok"] is False
assert "memories/note.md" in report["deleted"]
def test_fails_when_env_is_modified(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / ".env").write_text("CLOBBERED=1\n", encoding="utf-8")
report = _verify(home, snap)
assert report["ok"] is False
assert ".env" in report["modified"]
def test_tolerates_config_yaml_rewrite(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / "config.yaml").write_text("timezone: utc\nnew_key: 1\n", encoding="utf-8")
(home / "profiles" / "work" / "config.yaml").write_text("x: 1\ny: 2\n", encoding="utf-8")
report = _verify(home, snap)
assert report["ok"] is True, "config migration rewrites config.yaml additively"
assert sorted(report["tolerated_modified"]) == sorted(
["config.yaml", "profiles/work/config.yaml"])
def test_fails_when_state_db_loses_rows(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
con = sqlite3.connect(str(home / "state.db"))
con.execute("DELETE FROM sessions WHERE id='s1'")
con.commit()
con.close()
report = _verify(home, snap)
assert report["ok"] is False
assert report["rows_shrank"] == ["state.db"]
def test_tolerates_state_db_growth(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
con = sqlite3.connect(str(home / "state.db"))
con.execute("INSERT INTO sessions VALUES ('s9')")
con.commit()
con.close()
report = _verify(home, snap)
assert report["ok"] is True, "a later turn adds rows; that is not loss"
def test_bundled_skill_resync_is_advisory_only(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
# A release adds a bundled skill and updates an existing one.
(home / "skills" / "bar").mkdir()
(home / "skills" / "bar" / "SKILL.md").write_text("new bundled\n", encoding="utf-8")
(home / "skills" / "foo" / "SKILL.md").write_text("updated bundled\n", encoding="utf-8")
report = _verify(home, snap)
assert report["ok"] is True, "skills sync rewrites the bundled tree by design"
assert "skills/foo/SKILL.md" in report["advisory"]["modified"]
def test_bundled_skill_resync_inside_a_profile_is_advisory_only(tmp_path):
"""A second profile's bundled skills re-sync on update exactly like the
root's, so judging them would fail every leg that owns a profile."""
home = _home(tmp_path)
skills = home / "profiles" / "work" / "skills" / "foo"
skills.mkdir(parents=True)
(skills / "SKILL.md").write_text("bundled\n", encoding="utf-8")
snap = _snapshot(home)
# The update re-syncs every profile: bundled skill content moves.
(skills / "SKILL.md").write_text("updated bundled\n", encoding="utf-8")
report = _verify(home, snap)
assert report["ok"] is True, "a per-profile skills sync is not a user-state change"
assert "profiles/work/skills/foo/SKILL.md" in report["advisory"]["modified"]
assert "profiles/work/skills/foo/SKILL.md" not in report["modified"]
def test_fails_when_a_profile_skill_archive_is_lost(tmp_path):
"""skills/.archive/** holds restorable USER skills at both roots."""
home = _home(tmp_path)
archive = home / "profiles" / "work" / "skills" / ".archive" / "mine"
archive.mkdir(parents=True)
(archive / "SKILL.md").write_text("mine\n", encoding="utf-8")
snap = _snapshot(home)
(archive / "SKILL.md").unlink()
report = _verify(home, snap)
assert report["ok"] is False
assert "profiles/work/skills/.archive/mine/SKILL.md" in report["deleted"]
def test_fails_when_archived_user_skill_is_lost(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / "skills" / ".archive" / "mine" / "SKILL.md").unlink()
report = _verify(home, snap)
assert report["ok"] is False
assert "skills/.archive/mine/SKILL.md" in report["deleted"]
def test_fails_when_a_profile_directory_disappears(tmp_path):
home = _home(tmp_path)
snap = _snapshot(home)
(home / "profiles" / "work" / "config.yaml").unlink()
report = _verify(home, snap)
assert report["ok"] is False
assert "profiles/work/config.yaml" in report["deleted"]
# --- CLI contract -----------------------------------------------------------
def _run(args):
return subprocess.run([sys.executable, VERIFIER, *args],
capture_output=True, text=True)
def test_cli_snapshot_then_verify_round_trip(tmp_path):
home = _home(tmp_path)
snap = tmp_path / "snap.json"
report = tmp_path / "report.json"
first = _run(["snapshot", "--home", str(home), "--out", str(snap)])
assert first.returncode == 0, first.stderr
assert snap.exists()
ok = _run(["verify", "--home", str(home), "--snapshot", str(snap),
"--report", str(report)])
assert ok.returncode == 0, ok.stderr
assert json.loads(report.read_text(encoding="utf-8"))["ok"] is True
(home / "memories" / "note.md").unlink()
bad = _run(["verify", "--home", str(home), "--snapshot", str(snap)])
assert bad.returncode == 1
assert "USER-STATE PRESERVATION FAILED" in bad.stderr
def test_cli_refuses_an_empty_snapshot_as_inconclusive(tmp_path):
empty = tmp_path / "empty"
empty.mkdir()
snap = tmp_path / "snap.json"
result = _run(["snapshot", "--home", str(empty), "--out", str(snap)])
assert result.returncode == 3
assert "INCONCLUSIVE" in result.stderr
def test_cli_rejects_a_missing_home(tmp_path):
result = _run(["snapshot", "--home", str(tmp_path / "nope"),
"--out", str(tmp_path / "s.json")])
assert result.returncode == 2
def test_a_rewritten_dotenv_names_the_moved_variables_and_never_their_values(tmp_path):
"""An equal-size .env rewrite is invisible in the whole-file hash alone.
The app-driven upgrade rewrote .env at an identical byte count with a
different sha256, leaving the leg holding two hashes of a secrets file and no
lead. The report must name the variable -- and carry no value.
"""
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text(
"OPENAI_API_KEY=aaaa\n"
"OPENAI_BASE_URL=http://127.0.0.1:9001/v1\n"
"# a comment\n"
"export ANTHROPIC_API_KEY=cccc\n",
encoding="utf-8",
)
snap = vus.snapshot_home(str(home))
(home / ".env").write_text(
"OPENAI_API_KEY=bbbb\n"
"OPENAI_BASE_URL=http://127.0.0.1:9001/v1\n"
"# a comment\n"
"export ANTHROPIC_API_KEY=cccc\n"
"NEW_KEY=dddd\n",
encoding="utf-8",
)
report = vus.verify_home(str(home), snap)
diff = report["modified"][".env"]["key_diff"]
assert diff["keys_changed"] == ["OPENAI_API_KEY"]
assert diff["keys_added"] == ["NEW_KEY"]
assert diff["keys_removed"] == []
rendered = vus._render(report)
assert "changed=OPENAI_API_KEY" in rendered
assert "added=NEW_KEY" in rendered
# Names only: a secrets file's content must never reach the report.
for leaked in ("aaaa", "bbbb", "cccc", "dddd", "http://127.0.0.1:9001/v1"):
assert leaked not in rendered
assert leaked not in json.dumps(report)
def test_cron_ticker_stamps_move_but_user_cron_state_still_fails(tmp_path):
"""A live ticker rewrites its own liveness stamps; that is not user state.
On a cold home those files appear as additions (tolerated). Once the ticker
exists the same files register as modifications, which used to fail the user's
upgrade outward for the harness's own background process.
"""
home = tmp_path / "home"
(home / "cron").mkdir(parents=True)
(home / "profiles" / "p" / "cron").mkdir(parents=True)
(home / "cron" / "ticker_heartbeat").write_text("100\n", encoding="utf-8")
(home / "profiles" / "p" / "cron" / "ticker_last_success").write_text("100\n", encoding="utf-8")
(home / "cron" / "jobs.json").write_text('{"jobs": []}\n', encoding="utf-8")
snap = vus.snapshot_home(str(home))
(home / "cron" / "ticker_heartbeat").write_text("200\n", encoding="utf-8")
(home / "profiles" / "p" / "cron" / "ticker_last_success").write_text("200\n", encoding="utf-8")
(home / "cron" / "jobs.json").write_text('{"jobs": [1]}\n', encoding="utf-8")
report = vus.verify_home(str(home), snap)
assert set(report["modified"]) == {"cron/jobs.json"}
assert report["tolerated_modified"] == [
"cron/ticker_heartbeat", "profiles/p/cron/ticker_last_success"]
# The user's own cron DEFINITIONS are still state that must not move.
assert report["ok"] is False
def test_a_dotenv_changed_only_in_comments_is_reported_as_such(tmp_path):
"""The per-key digests ignore comments, order and blanks by design.
So a .env rewritten only in those parts reports as modified with no `variables`
line -- the real "0 deleted, 1 modified" with nothing named. The report must say
that explicitly, and still carry no values.
"""
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("# one\nOPENAI_BASE_URL=http://127.0.0.1:9000/v1\n", encoding="utf-8")
snap = vus.snapshot_home(str(home))
(home / ".env").write_text("# one\n# two\nOPENAI_BASE_URL=http://127.0.0.1:9000/v1\n", encoding="utf-8")
report = vus.verify_home(str(home), snap)
assert set(report["modified"]) == {".env"}
assert report["modified"][".env"]["key_diff"]["keys_changed"] == []
rendered = vus._render(report)
assert "no key differs" in rendered
assert "http://127.0.0.1:9000/v1" not in rendered
def test_sqlite_sidecars_may_vanish_but_the_database_may_not(tmp_path):
"""A WAL/shm pair exists only while a connection is open.
A snapshot taken with one live sees them; they vanish when it closes, and the
report then reads as the upgrade DELETING user state (seen live on
cron/executions.db-shm and -wal). The database itself must still fail if lost.
"""
home = tmp_path / "home"
(home / "cron").mkdir(parents=True)
for name in ("executions.db", "executions.db-wal", "executions.db-shm"):
(home / "cron" / name).write_text(f"{name}\n", encoding="utf-8")
snap = vus.snapshot_home(str(home))
os.remove(home / "cron" / "executions.db-wal")
os.remove(home / "cron" / "executions.db-shm")
report = vus.verify_home(str(home), snap)
assert report["deleted"] == []
assert report["tolerated_deleted"] == ["cron/executions.db-shm", "cron/executions.db-wal"]
assert report["ok"] is True
os.remove(home / "cron" / "executions.db")
report = vus.verify_home(str(home), snap)
assert report["deleted"] == ["cron/executions.db"]
assert report["ok"] is False
def test_any_sqlite_database_is_judged_by_rows_not_bytes(tmp_path):
"""Live SQLite files churn bytes constantly; their contract is the rows.
state.db was already handled that way; cron/executions.db was not, so a run
writing rows mid-window would have failed the leg as a byte modification.
"""
home = tmp_path / "home"
(home / "cron").mkdir(parents=True)
db = home / "cron" / "executions.db"
with sqlite3.connect(db) as conn:
# A table the verifier actually counts (COUNTED_TABLES), not an arbitrary one.
conn.execute("create table cron_jobs (id integer primary key, at text)")
conn.execute("insert into cron_jobs (at) values ('one')")
snap = vus.snapshot_home(str(home))
with sqlite3.connect(db) as conn: # rows added: bytes move, contract holds
conn.execute("insert into cron_jobs (at) values ('two')")
report = vus.verify_home(str(home), snap)
assert report["modified"] == {}
assert report["rows_shrank"] == []
assert report["ok"] is True
with sqlite3.connect(db) as conn: # rows lost: still fatal
conn.execute("delete from cron_jobs")
report = vus.verify_home(str(home), snap)
assert report["rows_shrank"] == ["cron/executions.db"]
assert report["ok"] is False
# --- retired provider vars ---------------------------------------------------
def test_retired_env_vars_come_from_the_migration_source(tmp_path):
"""The set is read from hermes_cli/config_migrations.py, not invented here.
The 12 -> 13 migration clears LLM_MODEL/OPENAI_MODEL: the old setup wizard
wrote them and nothing reads them now. A verifier carrying its own copy of
that list would silently stop following the tree the moment it changes.
"""
assert vus.retired_env_vars() == {"LLM_MODEL", "OPENAI_MODEL"}
# Unreadable or unparseable source retires nothing, so every .env change
# stays fatal rather than quietly becoming tolerated.
assert vus.retired_env_vars(tmp_path / "absent.py") == frozenset()
broken = tmp_path / "broken.py"
broken.write_text("def (:\n", encoding="utf-8")
assert vus.retired_env_vars(broken) == frozenset()
def test_a_retired_var_the_upgrade_empties_is_tolerated_and_named(tmp_path):
"""An old release's .env holds LLM_MODEL; the migration empties it.
This verifier exists to catch an upgrade taking the user's state away. A key
the tree itself retires is not that, so it is reported -- by name, never by
value -- instead of failing the leg.
"""
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text(
"LLM_MODEL=anthropic/claude-opus-4.6\nOPENROUTER_API_KEY=secret-value\n",
encoding="utf-8",
)
snap = vus.snapshot_home(str(home))
# Exactly the migration's write: same key, cleared value.
(home / ".env").write_text(
"LLM_MODEL=\nOPENROUTER_API_KEY=secret-value\n", encoding="utf-8"
)
report = vus.verify_home(str(home), snap)
assert report["ok"] is True, vus._render(report)
assert report["retired_env_cleared"] == {".env": ["LLM_MODEL"]}
rendered = vus._render(report)
assert "tolerated (retired var cleared: LLM_MODEL) .env" in rendered
assert "secret-value" not in rendered
assert "claude-opus" not in rendered
def test_a_live_key_cleared_by_an_upgrade_still_fails(tmp_path):
"""The exception is retired keys only -- emptying a real key is still loss."""
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("OPENROUTER_API_KEY=secret-value\n", encoding="utf-8")
snap = vus.snapshot_home(str(home))
(home / ".env").write_text("OPENROUTER_API_KEY=\n", encoding="utf-8")
report = vus.verify_home(str(home), snap)
assert report["ok"] is False
assert report["modified"][".env"]["key_diff"]["keys_changed"] == ["OPENROUTER_API_KEY"]
def test_a_retired_var_removed_outright_still_fails(tmp_path):
"""Clearing a retired key is the migration; deleting the line is not it."""
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("LLM_MODEL=anthropic/claude-opus-4.6\n", encoding="utf-8")
snap = vus.snapshot_home(str(home))
(home / ".env").write_text("", encoding="utf-8")
report = vus.verify_home(str(home), snap)
assert report["ok"] is False
assert report["modified"][".env"]["key_diff"]["keys_removed"] == ["LLM_MODEL"]