diff --git a/plugins/hermes-achievements/dashboard/plugin_api.py b/plugins/hermes-achievements/dashboard/plugin_api.py index c2f69a2299..643c306464 100644 --- a/plugins/hermes-achievements/dashboard/plugin_api.py +++ b/plugins/hermes-achievements/dashboard/plugin_api.py @@ -142,16 +142,47 @@ ACHIEVEMENTS: List[Dict[str, Any]] = [ ] +def _data_dir() -> Path: + """Durable data root (``/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]: diff --git a/plugins/plugin_storage.py b/plugins/plugin_storage.py new file mode 100644 index 0000000000..ca3e363a05 --- /dev/null +++ b/plugins/plugin_storage.py @@ -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 +``/plugins//``. 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 +``/plugin-data//``, 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.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. + + ``/plugin-data//`` — 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 ``/``. 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 diff --git a/tests/test_plugin_storage.py b/tests/test_plugin_storage.py new file mode 100644 index 0000000000..28f2ded8cf --- /dev/null +++ b/tests/test_plugin_storage.py @@ -0,0 +1,65 @@ +"""Tests for the per-plugin durable storage convention (plugins/plugin_storage). + +The contract under test: data lives under ``/plugin-data//`` +(NOT the ``plugins//`` 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="") diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 3ba389c4e0..8688d181a3 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -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 + +# /plugin-data// — created on first use +state_file = plugin_data_dir("my-plugin") / "state.json" + +# Or a SQLite database at /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`: