From 38253b1f5f9050f81d99dfc7892debcf42c2e34b Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:56:12 -0300 Subject: [PATCH 1/2] fix: percent-encode sqlite read-only URIs so spaced/special-char paths open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) mis-parses paths containing spaces or other URI-reserved characters — common in real home directories (a Windows "First Last" user folder, many macOS paths), and made worse by Windows backslashes. The database silently fails to open and the read-only fast paths fall back (or error) on those machines. Add config.sqlite_read_uri(), which percent-encodes the path via urllib.request.pathname2url (lazy-imported to keep config import light), and route every read-only sqlite reader through it: - mcp_server._tool_status_via_sqlite - searcher BM25 sqlite fallback - repair (status / scan / max-seq read paths) - backends/chroma (5 readers: counts, wing/room tally, id maps, etc.) All previously used the same naive f-string construction. Surfaced as a gemini-code-assist review note on #1837. --- mempalace/backends/chroma.py | 11 ++++++----- mempalace/config.py | 14 ++++++++++++++ mempalace/mcp_server.py | 3 ++- mempalace/repair.py | 7 ++++--- mempalace/searcher.py | 3 ++- tests/test_config.py | 27 +++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/mempalace/backends/chroma.py b/mempalace/backends/chroma.py index 15e074a..a50f13d 100644 --- a/mempalace/backends/chroma.py +++ b/mempalace/backends/chroma.py @@ -17,6 +17,7 @@ from typing import Any, Optional import chromadb from chromadb.errors import NotFoundError as _ChromaNotFoundError +from ..config import sqlite_read_uri from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar from .base import ( BaseBackend, @@ -457,7 +458,7 @@ def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]: if not os.path.isfile(db_path): return None try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) try: row = conn.execute( """ @@ -626,7 +627,7 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int: if not os.path.isfile(db_path): return 1000 try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) try: cur = conn.cursor() cur.execute( @@ -746,7 +747,7 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[ if not os.path.isfile(db_path): return None try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) try: row = conn.execute( """ @@ -807,7 +808,7 @@ def _sqlite_wing_room_counts( if not os.path.isfile(db_path): return None try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) try: # Wait out a transient writer/checkpoint lock rather than falling # straight back to the expensive vector-index path (#1681). @@ -1570,7 +1571,7 @@ class ChromaCollection(BaseCollection): # rowid, embedding_id is the user-facing drawer id. public_ids: dict[int, str] = {} try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) conn.row_factory = sqlite3.Row except sqlite3.Error: logger.debug("Chroma lexical sqlite open failed", exc_info=True) diff --git a/mempalace/config.py b/mempalace/config.py index d9808c0..80d6fda 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -205,6 +205,20 @@ DEFAULT_BACKEND = "chroma" DEFAULT_MAX_BACKUPS = 10 +def sqlite_read_uri(db_path: str) -> str: + """Return a read-only ``file:`` URI for ``sqlite3.connect(..., uri=True)``. + + A bare ``f"file:{db_path}?mode=ro"`` mis-parses paths containing spaces or + other URI-reserved characters — common in real home directories (a Windows + user folder like ``First Last``, many macOS paths). ``pathname2url`` + percent-encodes the path and normalizes separators so the database opens on + every platform. + """ + from urllib.request import pathname2url + + return f"file:{pathname2url(db_path)}?mode=ro" + + @lru_cache(maxsize=1) def get_configured_collection_name() -> str: """Return the configured drawer collection name without repeated config-file reads.""" diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index b63f5f3..6558d91 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -60,6 +60,7 @@ from .config import ( # noqa: E402 sanitize_name, sanitize_content, sanitize_iso_temporal, + sqlite_read_uri, strip_lone_surrogates, ) from .version import __version__ # noqa: E402 @@ -920,7 +921,7 @@ def _tool_status_via_sqlite() -> dict: rooms: dict = {} total = 0 try: - conn = _sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = _sqlite3.connect(sqlite_read_uri(db_path), uri=True) try: row = conn.execute( """ diff --git a/mempalace/repair.py b/mempalace/repair.py index 46de622..1ae1987 100644 --- a/mempalace/repair.py +++ b/mempalace/repair.py @@ -43,6 +43,7 @@ from typing import Callable, Iterator, Optional from chromadb.errors import NotFoundError as ChromaNotFoundError from .backends.chroma import ChromaBackend, hnsw_capacity_status +from .config import sqlite_read_uri COLLECTION_NAME = "mempalace_drawers" @@ -476,7 +477,7 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None) try: import sqlite3 - conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True) try: row = conn.execute( """ @@ -516,7 +517,7 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]: return [] try: - with sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) as conn: + with sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True) as conn: rows = conn.execute("PRAGMA quick_check").fetchall() except sqlite3.Error as e: return [f"PRAGMA quick_check failed: {e}"] @@ -1013,7 +1014,7 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple if not os.path.isfile(sqlite_path): return - conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True) try: seg_row = conn.execute( """ diff --git a/mempalace/searcher.py b/mempalace/searcher.py index 43796c3..239367b 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -23,6 +23,7 @@ from .backends import ( PalaceNotFoundError, UnsupportedCapabilityError, ) +from .config import sqlite_read_uri from .palace import ( _open_collection_or_explain, get_closets_collection, @@ -533,7 +534,7 @@ def _bm25_only_via_sqlite( return "".join(clauses), params try: - conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True) except sqlite3.Error as e: return {"error": f"sqlite open failed: {e}"} diff --git a/tests/test_config.py b/tests/test_config.py index d9ad8f9..acf818c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ import os import json +import sqlite3 import tempfile import pytest @@ -10,6 +11,7 @@ from mempalace.config import ( sanitize_iso_temporal, sanitize_kg_value, sanitize_name, + sqlite_read_uri, ) @@ -148,6 +150,31 @@ def test_embedding_threads_invalid_falls_back_to_auto(tmp_path, monkeypatch): assert cfg.embedding_threads == 2 +def test_sqlite_read_uri_opens_path_with_spaces(tmp_path): + """sqlite_read_uri must open a read-only DB whose path contains spaces, + which a bare f"file:{path}?mode=ro" mis-parses (especially on Windows).""" + db_dir = tmp_path / "palace with spaces" + db_dir.mkdir() + db_path = db_dir / "chroma.sqlite3" + setup = sqlite3.connect(str(db_path)) + setup.execute("CREATE TABLE t (x INTEGER)") + setup.execute("INSERT INTO t VALUES (42)") + setup.commit() + setup.close() + + uri = sqlite_read_uri(str(db_path)) + assert "%20" in uri # the space is percent-encoded, not left raw + + conn = sqlite3.connect(uri, uri=True) + try: + assert conn.execute("SELECT x FROM t").fetchone()[0] == 42 + # mode=ro is still honored through the encoded URI + with pytest.raises(sqlite3.OperationalError): + conn.execute("INSERT INTO t VALUES (1)") + finally: + conn.close() + + def test_env_override(): raw = "/env/palace" os.environ["MEMPALACE_PALACE_PATH"] = raw From 73772cb7079fc7cffe902053b1ea781fb33ad4e2 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:24:07 -0300 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- mempalace/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mempalace/config.py b/mempalace/config.py index 80d6fda..36a1703 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -216,6 +216,7 @@ def sqlite_read_uri(db_path: str) -> str: """ from urllib.request import pathname2url + db_path = os.fspath(db_path) return f"file:{pathname2url(db_path)}?mode=ro"