Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/mnemon/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""mnemon — Universal long-term memory layer for AI agents via MCP."""

__version__ = "0.7.0rc7"
__version__ = "0.7.0rc8"
29 changes: 13 additions & 16 deletions src/mnemon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions src/mnemon/hooks/_remote_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions src/mnemon/server_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/mnemon/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
42 changes: 41 additions & 1 deletion tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import pytest

from mnemon.store import Store
from mnemon.store import LocalVaultInaccessibleError, Store


@pytest.fixture
Expand Down Expand Up @@ -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()
Loading