superlocalmemory 3.6.11 → 3.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +2 -0
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/src/superlocalmemory/cli/commands.py +1 -0
  6. package/src/superlocalmemory/cli/daemon.py +0 -407
  7. package/src/superlocalmemory/cli/main.py +39 -24
  8. package/src/superlocalmemory/cli/version_banner.py +6 -2
  9. package/src/superlocalmemory/core/context_cache.py +4 -1
  10. package/src/superlocalmemory/core/fact_consolidator.py +4 -1
  11. package/src/superlocalmemory/core/remote_mode.py +197 -0
  12. package/src/superlocalmemory/core/summarizer.py +4 -1
  13. package/src/superlocalmemory/llm/backbone.py +7 -1
  14. package/src/superlocalmemory/mcp/agent_context.py +7 -3
  15. package/src/superlocalmemory/mcp/tools_core.py +13 -1
  16. package/src/superlocalmemory/mcp/tools_mesh.py +14 -6
  17. package/src/superlocalmemory/mesh/broker.py +15 -4
  18. package/src/superlocalmemory/optimize/compress/router.py +9 -4
  19. package/src/superlocalmemory/optimize/storage/db.py +16 -2
  20. package/src/superlocalmemory/server/api.py +11 -3
  21. package/src/superlocalmemory/server/routes/mesh.py +13 -0
  22. package/src/superlocalmemory/server/routes/token.py +14 -2
  23. package/src/superlocalmemory/server/routes/v3_api.py +83 -17
  24. package/src/superlocalmemory/server/ui.py +15 -4
  25. package/src/superlocalmemory/server/unified_daemon.py +96 -160
  26. package/src/superlocalmemory/storage/database.py +10 -1
  27. package/src/superlocalmemory/ui/js/auto-settings.js +24 -0
  28. package/src/superlocalmemory.egg-info/PKG-INFO +3 -1
  29. package/src/superlocalmemory.egg-info/SOURCES.txt +1 -0
@@ -0,0 +1,197 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Distributed / LAN deployment mode — the single ``SLM_REMOTE`` switch.
6
+
7
+ SuperLocalMemory historically assumes every dashboard browser, MCP client,
8
+ and API caller lives on ``127.0.0.1``. That assumption breaks three things
9
+ for users who deploy SLM on a server and reach it across a LAN (issue #39):
10
+
11
+ 1. ``/internal/token`` refuses any non-loopback client → Brain page can't
12
+ fetch the install token → "Couldn't load Brain".
13
+ 2. The MCP Streamable-HTTP transport is **stateful** — every call must
14
+ replay the ``Mcp-Session-Id`` from the ``initialize`` handshake. A
15
+ gateway/hub that forwards a tool call without replaying it gets
16
+ ``-32600 Session not found``.
17
+ 3. Dashboard CSRF origin checks only accept loopback origins.
18
+
19
+ ``SLM_REMOTE=1`` flips all three assumptions at once, **default OFF** so the
20
+ loopback-only security posture is unchanged for the 99% local case. LAN
21
+ access is still gated by an explicit IP allowlist (``SLM_MCP_ALLOWED_HOSTS``)
22
+ — remote mode alone does not throw the doors open.
23
+
24
+ Granular overrides (each implied by ``SLM_REMOTE=1`` but usable alone):
25
+ * ``SLM_MCP_STATELESS=1`` — stateless MCP transport only (gateway fix),
26
+ without opening the dashboard token endpoint.
27
+
28
+ Security note (WORSTCASE): stateless MCP drops per-session isolation, and
29
+ serving the install token to a LAN host lets any allowlisted machine read
30
+ the brain. Keep the allowlist specific (never blanket ``*`` unless the
31
+ network is fully trusted) — see ``docs/distributed-deployment.md``.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import ipaddress
37
+ import os
38
+
39
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
40
+
41
+
42
+ def _is_truthy(value: str | None) -> bool:
43
+ return bool(value) and value.strip().lower() in _TRUTHY
44
+
45
+
46
+ def is_remote_mode() -> bool:
47
+ """True iff ``SLM_REMOTE`` opts this daemon into LAN/distributed mode."""
48
+ return _is_truthy(os.environ.get("SLM_REMOTE"))
49
+
50
+
51
+ def mcp_stateless() -> bool:
52
+ """True iff the MCP transport should run stateless (no session id required).
53
+
54
+ Enabled by ``SLM_REMOTE=1`` (umbrella) or ``SLM_MCP_STATELESS=1`` (granular).
55
+ Stateless mode lets any gateway/hub forward ``tools/call`` without replaying
56
+ the ``Mcp-Session-Id`` handshake — the fix for issue #39 Issue 3.
57
+ """
58
+ return is_remote_mode() or _is_truthy(os.environ.get("SLM_MCP_STATELESS"))
59
+
60
+
61
+ def _allowlist_entries() -> list[str]:
62
+ """Trusted-client allowlist, from ``SLM_MCP_ALLOWED_HOSTS``.
63
+
64
+ Reuses the existing LAN allowlist the user already sets for MCP DNS-rebinding
65
+ protection so there is ONE place to configure trusted hosts. Entries are
66
+ comma-separated and may be: ``*`` (any), an exact IP, a CIDR block
67
+ (``192.168.1.0/24``), or a prefix wildcard (``192.168.*``). A trailing
68
+ ``:port`` / ``:*`` (host-header style) is ignored for client-IP matching.
69
+ """
70
+ raw = os.environ.get("SLM_MCP_ALLOWED_HOSTS", "").strip()
71
+ return [e.strip() for e in raw.split(",") if e.strip()]
72
+
73
+
74
+ def _strip_port(entry: str) -> str:
75
+ """Drop a trailing ``:port`` / ``:*`` host-header suffix.
76
+
77
+ Handles plain ``host[:port]`` and CIDR ``a.b.c.d/n[:port]`` (v3.6.12 lan-1:
78
+ a CIDR written with a host-header port suffix used to fail ip_network() and
79
+ silently deny ALL clients). Bracketless IPv6 literals (≥2 colons, no '/')
80
+ are left untouched.
81
+ """
82
+ e = entry.strip()
83
+ if "/" in e:
84
+ # CIDR — strip anything after the network prefix (a stray :port/:*)
85
+ return e.partition(":")[0]
86
+ if e.count(":") == 1: # host:port or host:* (IPv4 / hostname)
87
+ return e.split(":", 1)[0]
88
+ return e
89
+
90
+
91
+ def _host_matches(entry: str, client_host: str, client_ip) -> bool:
92
+ host = _strip_port(entry).strip()
93
+ if not host:
94
+ return False
95
+ if host == "*":
96
+ return True
97
+ if "/" in host and client_ip is not None:
98
+ try:
99
+ return client_ip in ipaddress.ip_network(host, strict=False)
100
+ except ValueError:
101
+ return False
102
+ if host.endswith("*"):
103
+ # STRING prefix match (not CIDR). client_host is always the numeric
104
+ # socket peer IP (never a resolvable hostname), and a dotted prefix like
105
+ # "192.168." rejects "192.1680.x". Prefer CIDR (192.168.0.0/16) for
106
+ # unambiguous network matching; wildcards are a convenience.
107
+ return client_host.startswith(host[:-1])
108
+ return host == client_host
109
+
110
+
111
+ def is_lan_client_allowed(client_host: str) -> bool:
112
+ """True iff remote mode is ON and ``client_host`` is in the trusted allowlist.
113
+
114
+ Loopback is handled separately by callers — this governs *non*-loopback LAN
115
+ clients only. Returns False whenever remote mode is off or the allowlist is
116
+ empty, so the default posture stays loopback-only.
117
+ """
118
+ if not is_remote_mode() or not client_host:
119
+ return False
120
+ entries = _allowlist_entries()
121
+ if not entries:
122
+ return False
123
+ try:
124
+ client_ip = ipaddress.ip_address(client_host)
125
+ except ValueError:
126
+ client_ip = None
127
+ return any(_host_matches(e, client_host, client_ip) for e in entries)
128
+
129
+
130
+ def is_remote_origin_allowed(origin: str) -> bool:
131
+ """True iff remote mode is ON and ``origin``'s host is in the allowlist.
132
+
133
+ ``origin`` is a full URL (``http://192.168.50.144:8765``). Empty origin is
134
+ not this function's concern (loopback callers handle that). Used to relax
135
+ the dashboard CSRF origin guard for trusted LAN dashboards.
136
+ """
137
+ if not is_remote_mode() or not origin:
138
+ return False
139
+ # Extract host from scheme://host[:port]
140
+ rest = origin.split("://", 1)[-1]
141
+ host = rest.split("/", 1)[0]
142
+ # Strip a trailing :port (IPv4/hostname); leave bracketed IPv6 alone.
143
+ if host.startswith("["):
144
+ host = host.split("]", 1)[0].lstrip("[")
145
+ elif host.count(":") == 1:
146
+ host = host.split(":", 1)[0]
147
+ return is_lan_client_allowed(host)
148
+
149
+
150
+ def _env_int(name: str, default: int) -> int:
151
+ """Read a positive int from env, falling back to ``default`` on any error."""
152
+ raw = os.environ.get(name, "").strip()
153
+ if not raw:
154
+ return default
155
+ try:
156
+ val = int(raw)
157
+ except ValueError:
158
+ return default
159
+ return val if val > 0 else default
160
+
161
+
162
+ def rate_limit_config() -> tuple[int, int, int]:
163
+ """(write_max, read_max, window_seconds) for the dashboard rate limiter.
164
+
165
+ Issue #40 Issue 3: the limiter was hardcoded (30 writes / 120 reads per 60s)
166
+ with no way to raise it for distributed/LAN debugging, so a remote browser
167
+ that retried a failing Brain load hit ``429 Too Many Requests``. These are
168
+ now tunable via ``SLM_RATE_LIMIT_WRITE`` / ``SLM_RATE_LIMIT_READ`` /
169
+ ``SLM_RATE_LIMIT_WINDOW`` (defaults unchanged for the local case).
170
+ """
171
+ write_max = _env_int("SLM_RATE_LIMIT_WRITE", 30)
172
+ read_max = _env_int("SLM_RATE_LIMIT_READ", 120)
173
+ window = _env_int("SLM_RATE_LIMIT_WINDOW", 60)
174
+ return write_max, read_max, window
175
+
176
+
177
+ def is_rate_limit_exempt(client_host: str) -> bool:
178
+ """True iff ``client_host`` should bypass the dashboard rate limiter.
179
+
180
+ Loopback is always exempt (the dashboard polls itself rapidly). In remote
181
+ mode, an allowlisted LAN client is the user's own remote browser doing the
182
+ same rapid reads, so it is exempt too — otherwise normal dashboard polling
183
+ trips the limiter (issue #40 Issue 3).
184
+ """
185
+ if client_host in ("127.0.0.1", "::1", "localhost"):
186
+ return True
187
+ return is_lan_client_allowed(client_host)
188
+
189
+
190
+ __all__ = (
191
+ "is_remote_mode",
192
+ "mcp_stateless",
193
+ "is_lan_client_allowed",
194
+ "is_remote_origin_allowed",
195
+ "rate_limit_config",
196
+ "is_rate_limit_exempt",
197
+ )
@@ -124,8 +124,11 @@ class Summarizer:
124
124
  """
125
125
  import httpx
126
126
  model = getattr(self._config.llm, 'model', None) or "llama3.1:8b"
127
+ # v3.6.12 (modeb-2): honor the configured endpoint instead of hardcoding
128
+ # localhost:11434, so a remote/non-default Ollama host works in Mode B.
129
+ _base = (getattr(self._config.llm, 'api_base', '') or "http://localhost:11434").rstrip("/")
127
130
  with httpx.Client(timeout=httpx.Timeout(30.0)) as client:
128
- resp = client.post("http://localhost:11434/api/generate", json={
131
+ resp = client.post(f"{_base}/api/generate", json={
129
132
  "model": model,
130
133
  "prompt": prompt,
131
134
  "stream": False,
@@ -138,7 +138,13 @@ class LLMBackbone:
138
138
  return False
139
139
  if self._provider == "ollama":
140
140
  return True
141
- return bool(self._api_key)
141
+ # v3.6.12 (modeb-1): a custom local OpenAI-compatible endpoint
142
+ # (llama.cpp, LM Studio, vLLM) needs NO API key — _build_openai already
143
+ # omits the Authorization header when the key is empty. Treat a
144
+ # configured base_url as sufficient, otherwise Mode B silently falls
145
+ # back to Mode A extraction for keyless local endpoints.
146
+ _base = getattr(self, "_base_url", "") or getattr(self, "_api_base", "")
147
+ return bool(self._api_key) or bool(_base)
142
148
 
143
149
  @property
144
150
  def provider(self) -> str:
@@ -15,8 +15,12 @@ import contextvars
15
15
  import os
16
16
  import re
17
17
 
18
+ # v3.6.12 (parity-1): default is "" (the "no agent routed" sentinel), NOT the
19
+ # user-visible "mcp_client". Sanitized agent ids are [A-Za-z0-9._-], so "" can
20
+ # never collide — a client that explicitly routes to /mcp/mcp_client is now
21
+ # distinguishable from a bare /mcp/ request with no agent segment.
18
22
  _current_agent_id: contextvars.ContextVar[str] = contextvars.ContextVar(
19
- "slm_agent_id", default="mcp_client"
23
+ "slm_agent_id", default=""
20
24
  )
21
25
 
22
26
  # Agent ids arrive from an untrusted URL path segment. They are ATTRIBUTION
@@ -42,8 +46,8 @@ def get_current_agent_id(env_fallback: bool = True) -> str:
42
46
  fall through to the SLM_AGENT_ID env var instead.
43
47
  """
44
48
  ctx_id = _current_agent_id.get()
45
- if ctx_id != "mcp_client":
46
- return ctx_id
49
+ if ctx_id:
50
+ return ctx_id # an explicitly-routed agent id (incl. "mcp_client")
47
51
  if env_fallback:
48
52
  return os.environ.get("SLM_AGENT_ID", "mcp_client")
49
53
  return "mcp_client"
@@ -309,7 +309,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
309
309
  try:
310
310
  engine = get_engine()
311
311
  pid = profile_id or engine.profile_id
312
- facts = engine._db.get_all_facts(pid)[:limit]
312
+ # v3.6.12 (search-2): push the limit into the query — was loading the
313
+ # ENTIRE facts table (deserializing every 768-float embedding) just
314
+ # to return the top N. get_all_facts preserves created_at DESC order.
315
+ facts = engine._db.get_all_facts(pid, limit=limit)
313
316
  items = []
314
317
  for f in facts:
315
318
  items.append({
@@ -401,6 +404,15 @@ def register_core_tools(server, get_engine: Callable) -> None:
401
404
  # Dashboard not installed — profile switch still works for MCP/CLI
402
405
  logger.debug("Dashboard routes not available, profile set in engine only")
403
406
 
407
+ # v3.6.12 (search-3): recall/delete run in a separate worker
408
+ # subprocess that caches its engine (and profile_id) at init. Recycle
409
+ # it so the NEXT recall uses the new profile instead of the stale one.
410
+ try:
411
+ from superlocalmemory.core.worker_pool import WorkerPool
412
+ WorkerPool.shared().shutdown()
413
+ except Exception:
414
+ logger.debug("worker-pool recycle on profile switch skipped")
415
+
404
416
  return {
405
417
  "success": True,
406
418
  "previous_profile": old,
@@ -76,7 +76,7 @@ def _mesh_request(method: str, path: str, body: dict | None = None) -> dict | No
76
76
 
77
77
  def _ensure_registered() -> None:
78
78
  """Register this session with the mesh broker if not already."""
79
- global _REGISTERED, _PROJECT_PATH
79
+ global _REGISTERED, _PROJECT_PATH, _PEER_ID
80
80
  if _REGISTERED:
81
81
  return
82
82
 
@@ -89,6 +89,11 @@ def _ensure_registered() -> None:
89
89
  "agent_type": os.environ.get("CLAUDE_AGENT_TYPE", "claude_code"),
90
90
  })
91
91
  if result:
92
+ # v3.6.12 (mesh-1): the broker mints its OWN peer_id (RegisterRequest has
93
+ # no peer_id field, so our body value is dropped by pydantic). Adopt the
94
+ # broker's id BEFORE starting the heartbeat, otherwise heartbeat/send/
95
+ # inbox all target a non-existent peer → 404s and the session is reaped.
96
+ _PEER_ID = result.get("peer_id", _PEER_ID)
92
97
  _REGISTERED = True
93
98
  _start_heartbeat()
94
99
  pending = result.get("pending_messages", 0)
@@ -191,7 +196,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
191
196
  _mesh_request, "POST", "/send",
192
197
  {"from_peer": _PEER_ID, "to_peer": to, "content": message},
193
198
  )
194
- return result or {"error": "Failed to send message"}
199
+ return result or {"ok": False, "error": "Failed to send message"}
195
200
 
196
201
  @server.tool()
197
202
  async def mesh_inbox() -> dict:
@@ -207,8 +212,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
207
212
  _mesh_request, "GET", f"/inbox/{_PEER_ID}?project_path={project}",
208
213
  )
209
214
  msg_list = (messages or {}).get("messages", [])
210
- # Auto-mark unread messages as read
211
- unread_ids = [m["id"] for m in msg_list if not m.get("read")]
215
+ # Auto-mark unread messages as read. v3.6.12 (failopen-2): use .get("id")
216
+ # a malformed broker message without an "id" key used to raise KeyError
217
+ # out to the agent, violating the never-raise contract.
218
+ unread_ids = [m["id"] for m in msg_list
219
+ if not m.get("read") and m.get("id") is not None]
212
220
  if unread_ids:
213
221
  await asyncio.to_thread(
214
222
  _mesh_request, "POST", f"/inbox/{_PEER_ID}/read",
@@ -239,7 +247,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
239
247
  _mesh_request, "POST", "/state",
240
248
  {"key": key, "value": value, "set_by": _PEER_ID},
241
249
  )
242
- return result or {"error": "Failed to set state"}
250
+ return result or {"ok": False, "error": "Failed to set state"}
243
251
 
244
252
  if key:
245
253
  result = await asyncio.to_thread(_mesh_request, "GET", f"/state/{key}")
@@ -266,7 +274,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
266
274
  _mesh_request, "POST", "/lock",
267
275
  {"file_path": file_path, "action": action, "locked_by": _PEER_ID},
268
276
  )
269
- return result or {"error": "Lock operation failed"}
277
+ return result or {"ok": False, "error": "Lock operation failed"}
270
278
 
271
279
  @server.tool(annotations=ToolAnnotations(readOnlyHint=True))
272
280
  async def mesh_events() -> dict:
@@ -281,10 +281,13 @@ class MeshBroker:
281
281
  try:
282
282
  now = datetime.now(timezone.utc).isoformat()
283
283
  # Direct messages to this peer
284
+ # v3.6.12 (mesh-3): only UNREAD direct messages — was returning read
285
+ # ones too, so every poll re-listed already-read messages until the
286
+ # 24h cleanup (broadcast/project already filter unread via mesh_reads).
284
287
  direct = conn.execute(
285
288
  "SELECT id, from_peer, to_peer, msg_type, content, read, created_at, "
286
289
  "target_type, project_path FROM mesh_messages "
287
- "WHERE to_peer=? AND target_type='peer' "
290
+ "WHERE to_peer=? AND target_type='peer' AND COALESCE(read, 0) = 0 "
288
291
  "AND (expires_at IS NULL OR expires_at > ?) "
289
292
  "ORDER BY created_at DESC LIMIT 100",
290
293
  (peer_id, now),
@@ -411,10 +414,18 @@ class MeshBroker:
411
414
  return {"ok": True, "action": "acquired"}
412
415
 
413
416
  elif action == "release":
414
- conn.execute("DELETE FROM mesh_locks WHERE file_path=? AND locked_by=?",
415
- (file_path, locked_by))
417
+ # v3.6.12 (mesh-2): report whether we actually released. The
418
+ # DELETE is correctly owner-scoped, but it previously returned
419
+ # released=ok:true even when a NON-owner released nothing.
420
+ cur = conn.execute(
421
+ "DELETE FROM mesh_locks WHERE file_path=? AND locked_by=?",
422
+ (file_path, locked_by),
423
+ )
416
424
  conn.commit()
417
- return {"ok": True, "action": "released"}
425
+ if cur.rowcount and cur.rowcount > 0:
426
+ return {"ok": True, "action": "released"}
427
+ return {"ok": False, "action": "not_released",
428
+ "error": "no lock held by this peer for that file"}
418
429
 
419
430
  elif action == "query":
420
431
  row = conn.execute(
@@ -317,11 +317,16 @@ class CompressRouter:
317
317
 
318
318
  @staticmethod
319
319
  def _normalize_whitespace(text: str) -> str:
320
- """Layer 1 lossless: collapse excess blank lines, strip trailing spaces per line."""
320
+ """Layer 1 safe: collapse runs of 3+ blank lines to a single blank line.
321
+
322
+ v3.6.12 (normalize-1): no longer rstrips trailing spaces per line — that
323
+ is LOSSY for Markdown hard breaks (two trailing spaces) and padded string
324
+ literals, which broke the 'lossless/safe' guarantee that callers (incl.
325
+ slm_compress mode=normalize) rely on. Only collapsing excess blank lines
326
+ remains, which is semantically safe.
327
+ """
321
328
  import re
322
- text = re.sub(r"\n{3,}", "\n\n", text)
323
- lines = [line.rstrip() for line in text.split("\n")]
324
- return "\n".join(lines)
329
+ return re.sub(r"\n{3,}", "\n\n", text)
325
330
 
326
331
  # ── Lazy loaders ─────────────────────────────────────────────────────
327
332
 
@@ -14,7 +14,12 @@ REUSE: DatabaseManager from superlocalmemory/src/superlocalmemory/storage/databa
14
14
  ENCRYPTION (resolves SEC-C-01 / CWE-312, NEW-M-01, NEW-M-02):
15
15
  - All value BLOBs (llmcache_entries.value_blob) are AES-256-GCM encrypted.
16
16
  - CCR original_blob is ALSO AES-256-GCM encrypted.
17
- - Key derivation: PBKDF2-HMAC-SHA256(password=machine_id, salt=_per_db_salt, iter=100_000)
17
+ - Key storage: a single MACHINE-WIDE key file (~/.superlocalmemory/opt-key.bin,
18
+ 0o600) is generated once and reused for all cache DBs on the machine. (The
19
+ per-DB salt below is persisted for provenance but does NOT make the AES key
20
+ per-DB — a single install has one llmcache.db, so a machine-wide key is the
21
+ intended model. A tampered/rotated key now degrades to a cache MISS, not a
22
+ crash — see _decrypt fail-open, v3.6.12 cache-1.)
18
23
  - Salt: os.urandom(32) generated ONCE at DB creation, stored in
19
24
  llmcache_schema_version.description='salt:<hex>'. NO hardcoded salt.
20
25
  - Nonce (12 bytes random) prepended to each ciphertext.
@@ -43,6 +48,7 @@ from dataclasses import dataclass, field
43
48
  from pathlib import Path
44
49
  from typing import Any
45
50
 
51
+ from cryptography.exceptions import InvalidTag
46
52
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
47
53
  from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
48
54
  from cryptography.hazmat.primitives import hashes
@@ -368,7 +374,15 @@ class CacheDB:
368
374
  nonce = blob[:_AES_NONCE_BYTES]
369
375
  ciphertext = blob[_AES_NONCE_BYTES:]
370
376
  aesgcm = AESGCM(self._aes_key)
371
- return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
377
+ # v3.6.12 (cache-1): AES-GCM raises cryptography.exceptions.InvalidTag
378
+ # (NOT a ValueError subclass) on a tampered/wrong-key blob. Every caller
379
+ # catches ValueError to fail-open; convert InvalidTag -> ValueError here
380
+ # at the single chokepoint so a corrupt/rotated-key cache entry degrades
381
+ # to a miss instead of raising out of get()/get_value()/ccr_get().
382
+ try:
383
+ return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
384
+ except InvalidTag as exc:
385
+ raise ValueError(f"AES-GCM authentication failed: {exc}") from exc
372
386
 
373
387
  # ---- assertion ----
374
388
 
@@ -108,12 +108,20 @@ def create_app() -> FastAPI:
108
108
  # Rate limiting (graceful)
109
109
  try:
110
110
  from superlocalmemory.infra.rate_limiter import RateLimiter
111
- _write_limiter = RateLimiter(max_requests=30, window_seconds=60)
112
- _read_limiter = RateLimiter(max_requests=120, window_seconds=60)
111
+ from superlocalmemory.core.remote_mode import (
112
+ rate_limit_config,
113
+ is_rate_limit_exempt,
114
+ )
115
+ # v3.6.12 (issue #40): env-tunable thresholds (defaults unchanged).
116
+ _rl_write, _rl_read, _rl_window = rate_limit_config()
117
+ _write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
118
+ _read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
113
119
 
114
120
  @application.middleware("http")
115
121
  async def rate_limit_middleware(request, call_next):
116
122
  client_ip = request.client.host if request.client else "unknown"
123
+ if is_rate_limit_exempt(client_ip):
124
+ return await call_next(request)
117
125
  is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
118
126
  limiter = _write_limiter if is_write else _read_limiter
119
127
  allowed, remaining = limiter.is_allowed(client_ip)
@@ -122,7 +130,7 @@ def create_app() -> FastAPI:
122
130
  return JSONResponse(
123
131
  status_code=429,
124
132
  content={"error": "Too many requests."},
125
- headers={"Retry-After": str(limiter.window_seconds)},
133
+ headers={"Retry-After": str(limiter.window)},
126
134
  )
127
135
  response = await call_next(request)
128
136
  response.headers["X-RateLimit-Remaining"] = str(remaining)
@@ -77,6 +77,19 @@ def _get_broker(request: Request):
77
77
  config = getattr(request.app.state, 'config', None)
78
78
  if config and not getattr(config, 'mesh_enabled', True):
79
79
  raise HTTPException(503, detail="Mesh disabled in config")
80
+ # v3.6.12 (mesh-1 security): SLM_MESH_SHARED_SECRET was read by the broker but
81
+ # never verified on inbound mesh HTTP calls. When a secret is configured,
82
+ # require it (constant-time) from NON-loopback callers via X-Mesh-Secret.
83
+ # The local MCP client always calls over loopback and is exempt, so this is
84
+ # zero-change for single-machine use and closes the LAN mesh auth bypass.
85
+ secret = getattr(broker, "_shared_secret", None)
86
+ if secret:
87
+ client_host = request.client.host if request.client else ""
88
+ if client_host not in ("127.0.0.1", "::1", "localhost"):
89
+ import hmac
90
+ presented = request.headers.get("x-mesh-secret", "")
91
+ if not hmac.compare_digest(presented, secret):
92
+ raise HTTPException(401, detail="invalid or missing mesh secret")
80
93
  return broker
81
94
 
82
95
 
@@ -33,8 +33,11 @@ router = APIRouter(tags=["internal"])
33
33
 
34
34
  _ALLOWED_ORIGIN_PREFIXES = (
35
35
  "http://127.0.0.1",
36
+ "https://127.0.0.1",
36
37
  "http://localhost",
38
+ "https://localhost",
37
39
  "http://[::1]",
40
+ "https://[::1]",
38
41
  )
39
42
 
40
43
 
@@ -57,13 +60,22 @@ async def get_token(request: Request) -> JSONResponse:
57
60
  logger.debug("token: primitives unimportable: %s", exc)
58
61
  return JSONResponse({"error": "server_error"}, status_code=500)
59
62
 
63
+ # v3.6.12 (issue #39): in SLM_REMOTE mode, also serve the token to
64
+ # explicitly-allowlisted LAN clients so a remote-browser dashboard can load
65
+ # the Brain page. Default stays loopback-only — remote_mode helpers return
66
+ # False unless SLM_REMOTE=1 AND the client IP is in SLM_MCP_ALLOWED_HOSTS.
67
+ from superlocalmemory.core.remote_mode import (
68
+ is_lan_client_allowed,
69
+ is_remote_origin_allowed,
70
+ )
71
+
60
72
  client_host = request.client.host if request.client else ""
61
- if not is_loopback(client_host):
73
+ if not is_loopback(client_host) and not is_lan_client_allowed(client_host):
62
74
  return JSONResponse({"error": "loopback only"}, status_code=403)
63
75
 
64
76
  headers = {k.lower(): v for k, v in request.headers.items()}
65
77
  origin = headers.get("origin", "")
66
- if not _origin_is_loopback(origin):
78
+ if not _origin_is_loopback(origin) and not is_remote_origin_allowed(origin):
67
79
  return JSONResponse(
68
80
  {"error": "origin not allowed"}, status_code=403,
69
81
  )