From 0185921c335f0f0586af965bdde2edb9183af812 Mon Sep 17 00:00:00 2001 From: Brian McMahon Date: Thu, 4 Jun 2026 10:47:13 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20local=20vault=20inaccessible=20in=20remo?= =?UTF-8?q?te=20mode=20=E2=80=94=20close=20the=20two-vaults=20residual=20(?= =?UTF-8?q?rc8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #188 (rc7) fixed `mnemon serve` to proxy to the remote, but every OTHER default-vault open (rebuild/forget/standing/doctor/sync CLI, dashboard, api) still opened — and re-created — a local ~/.mnemon/default.sqlite when a remote vault is configured, silently resurrecting a second source of truth. - Store.__init__ now refuses to open the DEFAULT local vault in remote mode and raises LocalVaultInaccessibleError (fail loud, never empty). Exempt: explicit db_path (tests/migrations), serve-remote (it IS the vault — sets the override), and MNEMON_ALLOW_LOCAL_STORE=1. - remote_mode_active() lifted to hooks._remote_client as the shared chokepoint; cli._remote_mode_active re-exports it (test patches preserved). - tests/conftest.py autouse fixture makes the suite hermetic w.r.t. the dev machine's ~/.mnemon/remote_url (bypasses the guard; the guard's own tests control the env per-test). Principle: if a cloud vault exists, the local vault must be inaccessible. +4 store-guard tests; full suite 1016 passed. rc7 → rc8. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 23 ++++++++++++++++ pyproject.toml | 2 +- src/mnemon/__init__.py | 2 +- src/mnemon/cli.py | 29 +++++++++------------ src/mnemon/hooks/_remote_client.py | 18 +++++++++++++ src/mnemon/server_remote.py | 8 ++++++ src/mnemon/store.py | 31 ++++++++++++++++++++++ tests/conftest.py | 18 +++++++++++++ tests/test_store.py | 42 +++++++++++++++++++++++++++++- 9 files changed, 154 insertions(+), 19 deletions(-) create mode 100644 tests/conftest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e2241..b3b9825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.7.0rc8] - 2026-06-04 + +### Fix: local vault is inaccessible in remote mode (two-vaults bug, residual) + +PR #188 (rc7) made `mnemon serve` proxy to the remote, but every *other* +default-vault open — the local-vault CLI commands (`rebuild` / `forget` / +`standing` / `doctor` / `sync`), the dashboard, and the api — still opened, +and would **re-create**, a local `~/.mnemon/default.sqlite` when a remote +vault is configured. That silently resurrects a second, divergent source of +truth (the same two-vaults trap, now via a freshly-created empty vault). + +The `Store` constructor now refuses to open the **default** local vault in +remote mode (`MNEMON_REMOTE_URL` / `~/.mnemon/remote_url`) and fails loud with +`LocalVaultInaccessibleError`, instead of silently serving/creating a local +vault. Exempt: an explicit `db_path` (tests, migrations), the `serve-remote` +server (it *is* the vault), and `MNEMON_ALLOW_LOCAL_STORE=1` for genuine local +maintenance. `remote_mode_active()` is lifted to `hooks._remote_client` as the +shared chokepoint for both the CLI router and the Store guard. + +Closes the residual hole behind the principle: **if a cloud vault exists, the +local vault must be inaccessible** — a second reachable source of truth is a +silent-divergence trap. + ## [0.7.0rc7] - 2026-06-04 ### Fix: `mnemon serve` honors remote-vault mode (two-vaults bug) diff --git a/pyproject.toml b/pyproject.toml index 6f9bbaf..6e438a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mnemon-memory" -version = "0.7.0rc7" +version = "0.7.0rc8" description = "Universal long-term memory layer for AI agents via MCP" readme = "README.md" license = "MIT" diff --git a/src/mnemon/__init__.py b/src/mnemon/__init__.py index b10b14b..8fb0134 100644 --- a/src/mnemon/__init__.py +++ b/src/mnemon/__init__.py @@ -1,3 +1,3 @@ """mnemon — Universal long-term memory layer for AI agents via MCP.""" -__version__ = "0.7.0rc7" +__version__ = "0.7.0rc8" diff --git a/src/mnemon/cli.py b/src/mnemon/cli.py index 779a481..05e6c18 100644 --- a/src/mnemon/cli.py +++ b/src/mnemon/cli.py @@ -6,11 +6,14 @@ is set (env var or ``~/.mnemon/remote_url`` file) — in remote mode ``serve`` runs the stdio proxy in :mod:`mnemon.server_proxy`, never opening the local store. Otherwise they hit the local -``~/.mnemon/default.sqlite``. Local-only commands (``sync``, +``~/.mnemon/default.sqlite``. Local-vault commands (``sync``, ``rebuild``, ``forget``, ``standing``, ``attention-status``, -``doctor``) intentionally stay on the local path — they're either -server-administration (rebuild/sync) or operator-explicit gestures -on the local vault. +``doctor``) operate on the local path — but in remote mode the +``Store`` chokepoint refuses to open the default local vault and +fails loud (``LocalVaultInaccessibleError``), so a cloud-pointed +machine can never silently re-create + touch a second local vault. +Run those with ``MNEMON_ALLOW_LOCAL_STORE=1`` for genuine local +maintenance. """ from __future__ import annotations @@ -24,19 +27,13 @@ def _remote_mode_active() -> bool: """True iff a remote vault is configured. - Mirrors ``hooks._remote_client.get_remote_url`` resolution order: - env var first, then ``~/.mnemon/remote_url`` file. Doesn't validate - the URL — that's the caller's job at first network use. + Thin re-export of ``hooks._remote_client.remote_mode_active`` (the + shared chokepoint, also keyed on by the ``Store`` guard) — kept under + this name so existing callers/tests that patch ``cli._remote_mode_active`` + are unaffected. """ - if os.environ.get("MNEMON_REMOTE_URL", "").strip(): - return True - from .hooks._remote_client import REMOTE_URL_FILE - if REMOTE_URL_FILE.exists(): - try: - return bool(REMOTE_URL_FILE.read_text().strip()) - except OSError: - return False - return False + from .hooks._remote_client import remote_mode_active + return remote_mode_active() def main() -> None: diff --git a/src/mnemon/hooks/_remote_client.py b/src/mnemon/hooks/_remote_client.py index 63c5198..7218904 100644 --- a/src/mnemon/hooks/_remote_client.py +++ b/src/mnemon/hooks/_remote_client.py @@ -89,6 +89,24 @@ def get_remote_url() -> str: ) +def remote_mode_active() -> bool: + """True iff a remote vault is configured (non-raising probe). + + Same resolution order as :func:`get_remote_url` (env → file) but + returns a bool instead of raising — the low-level chokepoint both the + CLI router and the Store guard key on so a machine pointed at a cloud + vault never silently opens the local one. + """ + if os.environ.get("MNEMON_REMOTE_URL", "").strip(): + return True + if REMOTE_URL_FILE.exists(): + try: + return bool(REMOTE_URL_FILE.read_text().strip()) + except OSError: + return False + return False + + def get_local_token() -> str: """Resolve the mnemon local bearer token. diff --git a/src/mnemon/server_remote.py b/src/mnemon/server_remote.py index 2a1322c..5866acd 100644 --- a/src/mnemon/server_remote.py +++ b/src/mnemon/server_remote.py @@ -58,6 +58,14 @@ def run_remote() -> None: the embedder is ready, which means clients see a brief connection delay during cold start instead of an in-flight tool-call timeout. """ + # This process IS the vault — it must serve its local Store regardless of + # any ambient remote config (a stray MNEMON_REMOTE_URL env, or an inherited + # ~/.mnemon/remote_url file when run on a machine that also acts as a + # client). The Store remote-mode guard targets *clients* opening a second + # local vault, not the authoritative server. setdefault so an explicit + # override still wins. + os.environ.setdefault("MNEMON_ALLOW_LOCAL_STORE", "1") + from .server import mcp # Eager embedder init — non-fatal if it fails (lazy load will retry diff --git a/src/mnemon/store.py b/src/mnemon/store.py index 8573950..581b615 100644 --- a/src/mnemon/store.py +++ b/src/mnemon/store.py @@ -176,8 +176,39 @@ def _row_to_document(row: sqlite3.Row) -> Document: ) +class LocalVaultInaccessibleError(RuntimeError): + """Raised when the default local vault is opened while a remote is configured. + + Brian's principle: if a cloud vault exists, the local vault must be + *inaccessible* — a second reachable source of truth is a silent-divergence + trap (the 2026-06-04 two-vaults bug). ``serve`` already proxies to the + remote (PR #188); this closes the residual hole where every *other* + default-vault open (``rebuild`` / ``forget`` / ``standing`` / ``doctor`` / + ``sync`` / dashboard / api) would silently re-create + touch a local vault + in remote mode. Fail loud, never empty. + """ + + class Store: def __init__(self, db_path: str | Path | None = None, vector_dim: int = 384): + # Chokepoint guard: opening the DEFAULT local vault while a remote is + # configured is forbidden (the local vault is out of commission in + # remote mode). An explicit ``db_path`` (tests, migrations) and the + # remote server (not in remote mode) are unaffected; the override env + # is the escape hatch for genuine local maintenance. + if db_path is None and not os.environ.get("MNEMON_ALLOW_LOCAL_STORE"): + from .hooks._remote_client import remote_mode_active + + if remote_mode_active(): + raise LocalVaultInaccessibleError( + "A remote mnemon vault is configured (MNEMON_REMOTE_URL / " + "~/.mnemon/remote_url); the local vault is out of commission " + "to prevent a second, divergent source of truth. Use the " + "remote (the memory_* MCP tools, or `mnemon status/search/" + "save` which route remote). For genuine local-vault " + "maintenance set MNEMON_ALLOW_LOCAL_STORE=1." + ) + path = Path(db_path) if db_path else vault_path() path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3b030e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +"""Shared test fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def _allow_local_store(monkeypatch): + """Make the suite hermetic w.r.t. the dev machine's remote config. + + The ``Store`` chokepoint refuses to open the *default* local vault when a + remote is configured (``~/.mnemon/remote_url`` / ``MNEMON_REMOTE_URL``) — + the two-vaults-bug guard. On a developer machine that points at a cloud + vault that would make every bare ``Store()`` in the suite raise. Tests + operate on local/temp stores by design, so bypass the guard here via the + documented override env. ``TestRemoteModeGuard`` deletes this env per-test + to exercise the guard itself. + """ + monkeypatch.setenv("MNEMON_ALLOW_LOCAL_STORE", "1") diff --git a/tests/test_store.py b/tests/test_store.py index 44edc86..5717456 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -6,7 +6,7 @@ import pytest -from mnemon.store import Store +from mnemon.store import LocalVaultInaccessibleError, Store @pytest.fixture @@ -856,3 +856,43 @@ def test_sweep_dry_run_does_not_delete(self, store): result = store.sweep(dry_run=True) # Note half-life is 60 days, fresh doc won't be a candidate assert result["archived"] == 0 + + +class TestRemoteModeGuard: + """In remote mode the default local vault is out of commission (two-vaults + bug fix). Opening it fails loud; explicit paths + override env are exempt.""" + + def test_default_vault_blocked_in_remote_mode(self, monkeypatch): + monkeypatch.setattr( + "mnemon.hooks._remote_client.remote_mode_active", lambda: True + ) + monkeypatch.delenv("MNEMON_ALLOW_LOCAL_STORE", raising=False) + with pytest.raises(LocalVaultInaccessibleError): + Store() + + def test_explicit_db_path_bypasses_guard(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "mnemon.hooks._remote_client.remote_mode_active", lambda: True + ) + s = Store(db_path=str(tmp_path / "explicit.sqlite")) + assert s.db_path.endswith("explicit.sqlite") + s.close() + + def test_override_env_bypasses_guard(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "mnemon.hooks._remote_client.remote_mode_active", lambda: True + ) + monkeypatch.setenv("MNEMON_ALLOW_LOCAL_STORE", "1") + monkeypatch.setattr("mnemon.store.vault_path", lambda: tmp_path / "ov.sqlite") + s = Store() + assert s.db_path.endswith("ov.sqlite") + s.close() + + def test_not_remote_mode_default_opens(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "mnemon.hooks._remote_client.remote_mode_active", lambda: False + ) + monkeypatch.setattr("mnemon.store.vault_path", lambda: tmp_path / "local.sqlite") + s = Store() + assert s.db_path.endswith("local.sqlite") + s.close()