Files
hermes-agent/tests/test_plugin_storage.py
Brooklyn Nicholson 8f2ddc9676 feat(plugins): per-plugin durable data directory that survives plugin update and removal
Plugins that persist state have been writing into their own install tree
(<hermes home>/plugins/<name>/), which `hermes plugins update` git-pulls and
`hermes plugins remove` deletes — user data dies with the code that wrote it.

plugins/plugin_storage.py is the sanctioned home: plugin_data_dir(name) gives
one data root per plugin under <hermes home>/plugin-data/<name>/ (profile-
aware, created on first use, names validated against traversal), and
plugin_db(name) opens a WAL-mode SQLite database inside it. Secrets stay on
the existing secret-scope path — this is state, not credentials.

hermes-achievements, the in-tree offender, converts with a legacy-file
migration on first read.
2026-08-17 15:12:53 -05:00

66 lines
2.0 KiB
Python

"""Tests for the per-plugin durable storage convention (plugins/plugin_storage).
The contract under test: data lives under ``<hermes home>/plugin-data/<name>/``
(NOT the ``plugins/<name>/`` install tree), names that could escape the root
are rejected, and the sqlite helper opens a WAL-mode connection inside the
data dir.
"""
from __future__ import annotations
import pytest
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from plugins.plugin_storage import plugin_data_dir, plugin_db
@pytest.fixture
def hermes_home(tmp_path):
token = set_hermes_home_override(str(tmp_path))
try:
yield tmp_path
finally:
reset_hermes_home_override(token)
def test_data_dir_lives_outside_the_install_tree(hermes_home):
root = plugin_data_dir("my-plugin")
assert root == hermes_home / "plugin-data" / "my-plugin"
assert root.is_dir()
# The invariant that motivated the module: data must not live under the
# install tree that `hermes plugins remove` deletes.
assert (hermes_home / "plugins") not in root.parents
def test_data_dir_is_stable_across_calls(hermes_home):
assert plugin_data_dir("p") == plugin_data_dir("p")
@pytest.mark.parametrize("bad", ["", ".", "..", "../escape", "a/b", "a\\b", "x" * 65])
def test_hostile_names_are_rejected(hermes_home, bad):
with pytest.raises(ValueError):
plugin_data_dir(bad)
def test_plugin_db_opens_wal_sqlite_in_the_data_dir(hermes_home):
conn = plugin_db("board")
try:
conn.execute("CREATE TABLE t (x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == "wal"
finally:
conn.close()
assert (hermes_home / "plugin-data" / "board" / "data.db").exists()
def test_plugin_db_rejects_path_shaped_filenames(hermes_home):
with pytest.raises(ValueError):
plugin_db("board", filename="../outside.db")
with pytest.raises(ValueError):
plugin_db("board", filename="")