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.
This commit is contained in:
committed by
brooklyn!
parent
59b1c40cdf
commit
8f2ddc9676
@@ -142,16 +142,47 @@ ACHIEVEMENTS: List[Dict[str, Any]] = [
|
||||
]
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""Durable data root (``<hermes home>/plugin-data/hermes-achievements/``).
|
||||
|
||||
Was the install tree (``plugins/hermes-achievements/``) before the
|
||||
plugin-data convention existed — state parked there died on
|
||||
``hermes plugins remove``/``update``. Legacy files migrate on first read.
|
||||
"""
|
||||
try:
|
||||
from plugins.plugin_storage import plugin_data_dir
|
||||
|
||||
return plugin_data_dir("hermes-achievements")
|
||||
except Exception:
|
||||
# Standalone dashboard import (no plugins package on sys.path):
|
||||
# keep the plugin working with the same layout, computed locally.
|
||||
root = get_hermes_home() / "plugin-data" / "hermes-achievements"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _data_file(name: str) -> Path:
|
||||
path = _data_dir() / name
|
||||
if not path.exists():
|
||||
legacy = get_hermes_home() / "plugins" / "hermes-achievements" / name
|
||||
if legacy.exists():
|
||||
try:
|
||||
path.write_text(legacy.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def state_path() -> Path:
|
||||
return get_hermes_home() / "plugins" / "hermes-achievements" / "state.json"
|
||||
return _data_file("state.json")
|
||||
|
||||
|
||||
def snapshot_path() -> Path:
|
||||
return get_hermes_home() / "plugins" / "hermes-achievements" / "scan_snapshot.json"
|
||||
return _data_file("scan_snapshot.json")
|
||||
|
||||
|
||||
def checkpoint_path() -> Path:
|
||||
return get_hermes_home() / "plugins" / "hermes-achievements" / "scan_checkpoint.json"
|
||||
return _data_file("scan_checkpoint.json")
|
||||
|
||||
|
||||
def load_state() -> Dict[str, Any]:
|
||||
|
||||
76
plugins/plugin_storage.py
Normal file
76
plugins/plugin_storage.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Per-plugin persistent storage convention.
|
||||
|
||||
Plugins that want durable state today invent their own paths, and most of
|
||||
them invent the same wrong one: a scratch directory inside
|
||||
``<hermes home>/plugins/<name>/``. That tree is the plugin *install* dir —
|
||||
``hermes plugins remove`` deletes it and ``hermes plugins update`` git-pulls
|
||||
into it — so user data parked there dies with the code that wrote it.
|
||||
|
||||
This module is the sanctioned alternative: one data root per plugin under
|
||||
``<hermes home>/plugin-data/<name>/``, owned by the user, untouched by
|
||||
install/update/remove. Agent-built plugins get durable state without
|
||||
inventing a storage story, and every plugin's data is inspectable in one
|
||||
predictable place.
|
||||
|
||||
Secrets are deliberately NOT part of this convention — credential reads go
|
||||
through ``agent.secret_scope`` / ``.env`` like everywhere else in Hermes.
|
||||
|
||||
Usage::
|
||||
|
||||
from plugins.plugin_storage import plugin_data_dir, plugin_db
|
||||
|
||||
state_file = plugin_data_dir("my-plugin") / "state.json"
|
||||
|
||||
with plugin_db("my-plugin") as conn: # <data dir>/data.db
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS ...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["plugin_data_dir", "plugin_db"]
|
||||
|
||||
# Mirrors the plugin-name shape `hermes plugins install` accepts. Anything
|
||||
# else could escape the data root via separators or traversal.
|
||||
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
def _validate_name(name: str) -> str:
|
||||
if not _NAME_RE.fullmatch(name) or ".." in name:
|
||||
raise ValueError(f"invalid plugin name for storage: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def plugin_data_dir(name: str) -> Path:
|
||||
"""Return (and create) this plugin's durable data directory.
|
||||
|
||||
``<hermes home>/plugin-data/<name>/`` — survives plugin update and
|
||||
removal, and follows the active profile because it resolves through
|
||||
:func:`hermes_constants.get_hermes_home` on every call. Don't cache the
|
||||
result across profile switches.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
root = get_hermes_home() / "plugin-data" / _validate_name(name)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def plugin_db(name: str, filename: str = "data.db") -> sqlite3.Connection:
|
||||
"""Open this plugin's SQLite database (created on first use).
|
||||
|
||||
Lives at ``<data dir>/<filename>``. WAL mode so a dashboard reader and an
|
||||
agent-tool writer can coexist; ``check_same_thread=False`` matches the
|
||||
multi-threaded FastAPI/tool environment plugins actually run in — the
|
||||
caller still owns transaction discipline.
|
||||
"""
|
||||
if Path(filename).name != filename or not filename:
|
||||
raise ValueError(f"invalid plugin db filename: {filename!r}")
|
||||
|
||||
conn = sqlite3.connect(plugin_data_dir(name) / filename, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
65
tests/test_plugin_storage.py
Normal file
65
tests/test_plugin_storage.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""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="")
|
||||
@@ -659,6 +659,31 @@ with open(_DATA_FILE) as f:
|
||||
_DATA = yaml.safe_load(f)
|
||||
```
|
||||
|
||||
That's for files you *ship*. State you *write* is different — see the next
|
||||
section.
|
||||
|
||||
### Store durable state
|
||||
|
||||
Never write runtime state into your plugin directory: that's the install
|
||||
tree, and `hermes plugins update` / `remove` git-pull or delete it — your
|
||||
users' data dies with it. The sanctioned home is the per-plugin data root,
|
||||
which survives both and follows the active profile:
|
||||
|
||||
```python
|
||||
from plugins.plugin_storage import plugin_data_dir, plugin_db
|
||||
|
||||
# <hermes home>/plugin-data/<name>/ — created on first use
|
||||
state_file = plugin_data_dir("my-plugin") / "state.json"
|
||||
|
||||
# Or a SQLite database at <data dir>/data.db (WAL mode, thread-friendly)
|
||||
conn = plugin_db("my-plugin")
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY)")
|
||||
```
|
||||
|
||||
One directory per plugin means every plugin's data is inspectable in one
|
||||
predictable place. Secrets don't belong here — credential reads go through
|
||||
the standard `.env` / secret-scope path like everywhere else.
|
||||
|
||||
### Bundle skills
|
||||
|
||||
Plugins can ship skill files that the agent loads via `skill_view("plugin:skill")`. Register them in your `__init__.py`:
|
||||
|
||||
Reference in New Issue
Block a user