superlocalmemory 3.8.2 → 3.8.5

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 (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -32,6 +32,8 @@ from datetime import datetime, timezone
32
32
  from pathlib import Path
33
33
  from typing import Protocol, runtime_checkable
34
34
 
35
+ from superlocalmemory.storage.write_lock import get_write_lock
36
+
35
37
 
36
38
  # ---------------------------------------------------------------------------
37
39
  # Constants
@@ -86,25 +88,31 @@ def _ensure_memory_log(db_path: Path) -> None:
86
88
  """Lazily create ``cross_platform_sync_log`` if a test-mode memory.db is
87
89
  fresh. Production code goes through the migration runner, but tests can
88
90
  hand us an empty DB; this keeps adapters usable without pre-running
89
- migrations."""
90
- conn = sqlite3.connect(str(db_path))
91
- try:
92
- conn.executescript(
93
- "CREATE TABLE IF NOT EXISTS cross_platform_sync_log ("
94
- " adapter_name TEXT NOT NULL,"
95
- " profile_id TEXT NOT NULL,"
96
- " target_path_sha256 TEXT NOT NULL,"
97
- " target_basename TEXT NOT NULL,"
98
- " last_sync_at TEXT NOT NULL,"
99
- " bytes_written INTEGER NOT NULL,"
100
- " content_sha256 TEXT NOT NULL,"
101
- " success INTEGER NOT NULL,"
102
- " error_msg TEXT,"
103
- " PRIMARY KEY (adapter_name, target_path_sha256));"
104
- )
105
- conn.commit()
106
- finally:
107
- conn.close()
91
+ migrations.
92
+
93
+ Acquires the process-level write lock for *db_path* before opening
94
+ a sqlite3 connection so that this DDL write is serialised with all
95
+ other in-process writers (DatabaseManager, VectorStore, etc.).
96
+ """
97
+ with get_write_lock(db_path):
98
+ conn = sqlite3.connect(str(db_path))
99
+ try:
100
+ conn.executescript(
101
+ "CREATE TABLE IF NOT EXISTS cross_platform_sync_log ("
102
+ " adapter_name TEXT NOT NULL,"
103
+ " profile_id TEXT NOT NULL,"
104
+ " target_path_sha256 TEXT NOT NULL,"
105
+ " target_basename TEXT NOT NULL,"
106
+ " last_sync_at TEXT NOT NULL,"
107
+ " bytes_written INTEGER NOT NULL,"
108
+ " content_sha256 TEXT NOT NULL,"
109
+ " success INTEGER NOT NULL,"
110
+ " error_msg TEXT,"
111
+ " PRIMARY KEY (adapter_name, target_path_sha256));"
112
+ )
113
+ conn.commit()
114
+ finally:
115
+ conn.close()
108
116
 
109
117
 
110
118
  def sync_log_last_content_sha256(
@@ -156,31 +164,37 @@ def sync_log_record(
156
164
  )
157
165
  if os.sep in target_path_sha256 or "/" in target_path_sha256:
158
166
  raise ValueError("target_path_sha256 must be a hash, not a raw path")
159
- _ensure_memory_log(db_path)
160
- conn = sqlite3.connect(str(db_path))
161
- try:
162
- conn.execute(
163
- "INSERT INTO cross_platform_sync_log ("
164
- "adapter_name, profile_id, target_path_sha256, target_basename, "
165
- "last_sync_at, bytes_written, content_sha256, success, error_msg"
166
- ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
167
- "ON CONFLICT(adapter_name, target_path_sha256) DO UPDATE SET "
168
- " profile_id = excluded.profile_id,"
169
- " target_basename = excluded.target_basename,"
170
- " last_sync_at = excluded.last_sync_at,"
171
- " bytes_written = excluded.bytes_written,"
172
- " content_sha256 = excluded.content_sha256,"
173
- " success = excluded.success,"
174
- " error_msg = excluded.error_msg",
175
- (
176
- adapter_name, profile_id, target_path_sha256, target_basename,
177
- _now_iso(), bytes_written, content_sha256,
178
- 1 if success else 0, error_msg,
179
- ),
180
- )
181
- conn.commit()
182
- finally:
183
- conn.close()
167
+ # Acquire the process-level write lock BEFORE opening the sqlite3 connection.
168
+ # This ensures the INSERT/UPDATE below is serialised with all other in-process
169
+ # writers (DatabaseManager, VectorStore, consolidation) via the single shared
170
+ # RLock for memory.db, eliminating SQLITE_BUSY races at the WAL layer.
171
+ # _ensure_memory_log also acquires the same RLock (re-entrant — safe).
172
+ with get_write_lock(db_path):
173
+ _ensure_memory_log(db_path)
174
+ conn = sqlite3.connect(str(db_path))
175
+ try:
176
+ conn.execute(
177
+ "INSERT INTO cross_platform_sync_log ("
178
+ "adapter_name, profile_id, target_path_sha256, target_basename, "
179
+ "last_sync_at, bytes_written, content_sha256, success, error_msg"
180
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "
181
+ "ON CONFLICT(adapter_name, target_path_sha256) DO UPDATE SET "
182
+ " profile_id = excluded.profile_id,"
183
+ " target_basename = excluded.target_basename,"
184
+ " last_sync_at = excluded.last_sync_at,"
185
+ " bytes_written = excluded.bytes_written,"
186
+ " content_sha256 = excluded.content_sha256,"
187
+ " success = excluded.success,"
188
+ " error_msg = excluded.error_msg",
189
+ (
190
+ adapter_name, profile_id, target_path_sha256, target_basename,
191
+ _now_iso(), bytes_written, content_sha256,
192
+ 1 if success else 0, error_msg,
193
+ ),
194
+ )
195
+ conn.commit()
196
+ finally:
197
+ conn.close()
184
198
 
185
199
 
186
200
  # ---------------------------------------------------------------------------
@@ -188,8 +188,18 @@ class IDEConnector: # pragma: no cover — legacy shim, covered by test_ide_con
188
188
  path.write_text(content + "\n" + section)
189
189
  return True
190
190
 
191
- def _merge_json(self, path: Path) -> bool:
192
- """Merge SLM config into a JSON config file."""
191
+ def _merge_json(
192
+ self,
193
+ path: Path,
194
+ transport: str = "stdio",
195
+ daemon_port: int = 8765,
196
+ ) -> bool:
197
+ """Merge SLM config into a JSON config file.
198
+
199
+ Args:
200
+ transport: "stdio" (default), "http", or "http-mcp-remote".
201
+ daemon_port: Daemon port for http transports (default 8765).
202
+ """
193
203
  data: dict[str, Any] = {}
194
204
  if path.exists():
195
205
  try:
@@ -201,12 +211,20 @@ class IDEConnector: # pragma: no cover — legacy shim, covered by test_ide_con
201
211
  if "mcpServers" not in data:
202
212
  data["mcpServers"] = {}
203
213
 
204
- data["mcpServers"]["superlocalmemory"] = {
205
- "type": "stdio",
206
- "command": "slm",
207
- "args": ["mcp"],
208
- "enabled": True,
209
- }
214
+ base_url = f"http://127.0.0.1:{daemon_port}/mcp/"
215
+ if transport == "http":
216
+ block: dict[str, Any] = {"type": "http", "url": base_url}
217
+ elif transport == "http-mcp-remote":
218
+ block = {"type": "stdio", "command": "mcp-remote", "args": [base_url]}
219
+ else:
220
+ block = {
221
+ "type": "stdio",
222
+ "command": "slm",
223
+ "args": ["mcp"],
224
+ "enabled": True,
225
+ }
226
+
227
+ data["mcpServers"]["superlocalmemory"] = block
210
228
 
211
229
  path.parent.mkdir(parents=True, exist_ok=True)
212
230
  path.write_text(json.dumps(data, indent=2))
@@ -32,6 +32,12 @@ logger = logging.getLogger(__name__)
32
32
  SLM_MARKER_START = "<!-- SLM-START -->"
33
33
  SLM_MARKER_END = "<!-- SLM-END -->"
34
34
 
35
+ # Valid transport values for connect_ide() and slm connect --transport.
36
+ # "stdio" — default, zero regression; uses desc.server_block as-is.
37
+ # "http" — native MCP Streamable-HTTP; requires daemon at :daemon_port.
38
+ # "http-mcp-remote" — mcp-remote stdio bridge for stdio-only clients.
39
+ VALID_TRANSPORTS: frozenset[str] = frozenset({"stdio", "http", "http-mcp-remote"})
40
+
35
41
  CLAUDE_CODE_PLUGIN_POINTER = (
36
42
  "slm connect claude-code: Claude Code is configured via the SLM plugin (WP-06).\n"
37
43
  "Run: slm plugin install OR see plugin-src/ for manual installation.\n"
@@ -241,16 +247,26 @@ def connect_ide(
241
247
  profile: str | None = None,
242
248
  agents_md_source: Callable[[], str] | None = None,
243
249
  dry_run: bool = False,
250
+ transport: str = "stdio",
251
+ daemon_port: int = 8765,
244
252
  ) -> dict[str, Any]:
245
253
  """Wire SLM into the target IDE config via merge-not-clobber.
246
254
 
255
+ Args:
256
+ transport: MCP transport to write into the config.
257
+ "stdio" (default, zero regression) — uses the IDE's canonical server_block.
258
+ "http" — native MCP Streamable-HTTP block; requires SLM daemon at daemon_port.
259
+ "http-mcp-remote" — stdio bridge via mcp-remote for stdio-only clients.
260
+ daemon_port: Daemon listen port for http / http-mcp-remote (default 8765).
261
+
247
262
  Returns a result dict:
248
- {ide, mcp_config: wrote|merged|unchanged|would_write|skipped|error,
263
+ {ide, transport, mcp_config: wrote|merged|unchanged|would_write|skipped|error,
249
264
  mcp_path, agents_md: wrote|skipped(...)|unchanged|error,
250
265
  servers_preserved: int, error: str|None}
251
266
  """
252
267
  result: dict[str, Any] = {
253
268
  "ide": ide_id,
269
+ "transport": transport,
254
270
  "mcp_config": "error",
255
271
  "mcp_path": "",
256
272
  "agents_md": "skipped(not-run)",
@@ -258,6 +274,14 @@ def connect_ide(
258
274
  "error": None,
259
275
  }
260
276
 
277
+ # Step 0 — validate transport
278
+ if transport not in VALID_TRANSPORTS:
279
+ result["error"] = (
280
+ f"Invalid transport '{transport}'. "
281
+ f"Valid choices: {', '.join(sorted(VALID_TRANSPORTS))}"
282
+ )
283
+ return result
284
+
261
285
  # Step 1 — resolve
262
286
  desc = resolve_descriptor(ide_id)
263
287
  if desc is None:
@@ -303,9 +327,25 @@ def connect_ide(
303
327
  # File is untouched (we never wrote; abort)
304
328
  return result
305
329
 
306
- # Step 4 — extract server container
307
- # For continue (yaml list), special-case
330
+ # Step 4 — daemon health-check for HTTP transports (advisory, never blocks write)
331
+ if transport in ("http", "http-mcp-remote") and desc.fmt != "":
332
+ if not _check_daemon_health(daemon_port):
333
+ print(
334
+ f"[SLM] Warning: daemon not reachable at http://127.0.0.1:{daemon_port}/api/v3/health. "
335
+ f"Run `slm serve start` to start it, or `slm serve install` to register as an OS service.",
336
+ file=sys.stderr,
337
+ )
338
+
339
+ # Step 5 — extract server container and build block
340
+ # For YAML-format IDEs (continue.dev), transport is ignored — their block
341
+ # structure is list-based and incompatible with the JSON http/mcp-remote block.
308
342
  if desc.fmt == "yaml":
343
+ if transport != "stdio":
344
+ logger.warning(
345
+ "transport=%r is not supported for YAML-format IDE %r; using stdio fallback.",
346
+ transport,
347
+ ide_id,
348
+ )
309
349
  mcp_status, servers_preserved = _merge_yaml_list(
310
350
  data, desc, profile
311
351
  )
@@ -316,10 +356,8 @@ def connect_ide(
316
356
  pre_count = len(servers)
317
357
  pre_slm = copy.deepcopy(servers.get("superlocalmemory"))
318
358
 
319
- # Step 5 merge
320
- block = copy.deepcopy(desc.server_block)
321
- if profile:
322
- block.setdefault("env", {})["SLM_MCP_PROFILE"] = profile
359
+ # Build the server block for the requested transport
360
+ block = _build_server_block(desc, transport, daemon_port, profile)
323
361
 
324
362
  servers["superlocalmemory"] = block
325
363
 
@@ -384,6 +422,8 @@ def connect_many(
384
422
  here: bool = False,
385
423
  profile: str | None = None,
386
424
  agents_md_source: Callable[[], str] | None = None,
425
+ transport: str = "stdio",
426
+ daemon_port: int = 8765,
387
427
  ) -> list[dict[str, Any]]:
388
428
  """Wire SLM into multiple IDE configs via non-destructive merge.
389
429
 
@@ -402,14 +442,17 @@ def connect_many(
402
442
  home: Override ``$HOME`` (test hook).
403
443
  project: Project root for ``here=True`` installs.
404
444
  here: When True, write to project-relative path instead of global.
405
- profile: Inject ``SLM_MCP_PROFILE`` env-var into every server block.
445
+ profile: Inject ``SLM_MCP_PROFILE`` env-var (stdio) or URL param (http).
406
446
  agents_md_source: Callable returning AGENTS.md content to append.
447
+ transport: MCP transport to use for all IDEs ("stdio", "http",
448
+ "http-mcp-remote"). Default "stdio" preserves existing behavior.
449
+ daemon_port: Daemon listen port for http/http-mcp-remote (default 8765).
407
450
 
408
451
  Returns:
409
452
  List of per-IDE result dicts, one per input id. Each dict has the
410
453
  same shape as :func:`connect_ide`'s return value::
411
454
 
412
- {ide, mcp_config, mcp_path, agents_md, servers_preserved, error}
455
+ {ide, transport, mcp_config, mcp_path, agents_md, servers_preserved, error}
413
456
  """
414
457
  return [
415
458
  connect_ide(
@@ -419,6 +462,8 @@ def connect_many(
419
462
  here=here,
420
463
  profile=profile,
421
464
  agents_md_source=agents_md_source,
465
+ transport=transport,
466
+ daemon_port=daemon_port,
422
467
  )
423
468
  for ide_id in ide_ids
424
469
  ]
@@ -507,6 +552,57 @@ def _merge_yaml_list(
507
552
  return "wrote", pre_count
508
553
 
509
554
 
555
+ def _build_server_block(
556
+ desc: IDEDescriptor,
557
+ transport: str,
558
+ daemon_port: int,
559
+ profile: str | None,
560
+ ) -> dict[str, Any]:
561
+ """Return the server block for the requested transport.
562
+
563
+ YAML and TOML IDEs use a format-specific block structure that is
564
+ incompatible with the JSON http/mcp-remote block — caller should never
565
+ reach here for those (they take the yaml branch in connect_ide).
566
+ For JSON-format IDEs:
567
+ "stdio" → copy of desc.server_block with optional SLM_MCP_PROFILE env
568
+ "http" → native MCP HTTP block; profile goes as URL query param
569
+ "http-mcp-remote" → mcp-remote stdio bridge; profile appended to proxied URL
570
+ """
571
+ base_url = f"http://127.0.0.1:{daemon_port}/mcp/"
572
+
573
+ if transport == "http":
574
+ url = base_url + (f"?profile={profile}" if profile else "")
575
+ return {"type": "http", "url": url}
576
+
577
+ if transport == "http-mcp-remote":
578
+ url = base_url + (f"?profile={profile}" if profile else "")
579
+ return {"type": "stdio", "command": "mcp-remote", "args": [url]}
580
+
581
+ # Default: stdio — preserve existing behavior exactly
582
+ block = copy.deepcopy(desc.server_block)
583
+ if profile:
584
+ block.setdefault("env", {})["SLM_MCP_PROFILE"] = profile
585
+ return block
586
+
587
+
588
+ def _check_daemon_health(daemon_port: int, timeout: float = 2.0) -> bool:
589
+ """Non-blocking probe of the SLM daemon health endpoint.
590
+
591
+ Returns True if the daemon responds with HTTP 200, False otherwise.
592
+ Never raises — all exceptions are caught and treated as "unreachable".
593
+ """
594
+ try:
595
+ import urllib.request
596
+ import urllib.error
597
+
598
+ url = f"http://127.0.0.1:{daemon_port}/api/v3/health"
599
+ req = urllib.request.Request(url, method="GET")
600
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
601
+ return resp.status == 200
602
+ except Exception:
603
+ return False
604
+
605
+
510
606
  def _atomic_write(path: Path, data: dict[str, Any], fmt: str) -> None:
511
607
  """Serialize data and atomically write to path (.tmp + os.replace)."""
512
608
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -38,6 +38,8 @@ _ORIGIN_HEADER_VARIANTS: tuple[str, ...] = ("Origin", "origin")
38
38
 
39
39
  # Loopback addresses accepted by LLD-01. ``localhost`` is NOT included per
40
40
  # SEC-01-02 — we want literal IPs only to avoid DNS-based bypass tricks.
41
+ # Note: ::ffff:127.x.x.x is handled dynamically via ipaddress.ip_address()
42
+ # in is_loopback() below to cover dual-stack container deployments (#90).
41
43
  _LOOPBACK_ADDRS: frozenset[str] = frozenset({"127.0.0.1", "::1"})
42
44
 
43
45
  # Body-size cap: LLD-01 §4.5 step 4 → 8 KB.
@@ -65,10 +67,27 @@ class AuthDecision:
65
67
 
66
68
 
67
69
  def is_loopback(client_host: str) -> bool:
68
- """Return True iff ``client_host`` is an accepted loopback literal."""
70
+ """Return True iff ``client_host`` is an accepted loopback literal.
71
+
72
+ Deliberately excludes ``"localhost"`` per SEC-01-02 — /internal/prewarm
73
+ callers are in-process hooks that always connect to a literal IP, so
74
+ hostname aliases are rejected to prevent DNS-based bypass tricks.
75
+
76
+ Accepts IPv4-mapped IPv6 loopback (``::ffff:127.x.x.x``) for dual-stack
77
+ correctness (issue #90), while still excluding ``"localhost"``.
78
+ """
69
79
  if not isinstance(client_host, str) or not client_host:
70
80
  return False
71
- return client_host in _LOOPBACK_ADDRS
81
+ if client_host in _LOOPBACK_ADDRS:
82
+ return True
83
+ # Handle IPv4-mapped IPv6 loopback (::ffff:127.x.x.x) — dual-stack fix.
84
+ # Intentionally NOT accepting "localhost" (SEC-01-02 preserved).
85
+ try:
86
+ import ipaddress as _ipa
87
+ ip = _ipa.ip_address(client_host)
88
+ return ip.is_loopback
89
+ except ValueError:
90
+ return False
72
91
 
73
92
 
74
93
  # ---------------------------------------------------------------------------
@@ -137,7 +137,9 @@ def authorize_http_mcp_request(
137
137
  peer must present the configured SLM API key. The LAN allowlist limits
138
138
  reachability but deliberately does not grant a write identity.
139
139
  """
140
- if client_host in ("127.0.0.1", "::1", "localhost"):
140
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
141
+
142
+ if _is_loopback_host(client_host):
141
143
  return True
142
144
  provided = request_headers.get("x-slm-api-key", "")
143
145
  return verify_api_key(provided, key_file=key_file)
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any
27
27
 
28
28
  from superlocalmemory.infra.data_root import canonical_data_root
29
+ from superlocalmemory.storage.memory_write import memory_write
29
30
 
30
31
  logger = logging.getLogger("superlocalmemory.cloud_backup")
31
32
 
@@ -248,8 +249,7 @@ def add_destination(
248
249
 
249
250
  dest_id = _new_id()
250
251
  path = db_path or _default_db_path()
251
- conn = sqlite3.connect(str(path))
252
- try:
252
+ with memory_write(path) as conn:
253
253
  conn.execute(
254
254
  "INSERT INTO backup_destinations "
255
255
  "(id, destination_type, display_name, credentials_ref, config, "
@@ -257,32 +257,34 @@ def add_destination(
257
257
  (dest_id, destination_type, display_name, credentials_ref,
258
258
  json.dumps(config), datetime.now(UTC).isoformat()),
259
259
  )
260
- conn.commit()
261
- logger.info("Added backup destination: %s (%s)", display_name, destination_type)
262
- return dest_id
263
- finally:
264
- conn.close()
260
+ logger.info("Added backup destination: %s (%s)", display_name, destination_type)
261
+ return dest_id
265
262
 
266
263
 
267
264
  def remove_destination(dest_id: str, db_path: Path | None = None) -> bool:
268
265
  """Remove a backup destination and its credentials."""
269
266
  path = db_path or _default_db_path()
270
- conn = sqlite3.connect(str(path))
271
267
  try:
272
- row = conn.execute(
273
- "SELECT credentials_ref FROM backup_destinations WHERE id = ?",
274
- (dest_id,),
275
- ).fetchone()
276
- if row and row[0]:
277
- _delete_credential(row[0])
278
- conn.execute("DELETE FROM backup_destinations WHERE id = ?", (dest_id,))
279
- conn.commit()
268
+ # Fetch credentials_ref with a short read before taking the write lock.
269
+ import sqlite3 as _sqlite3
270
+ creds_ref = None
271
+ try:
272
+ with _sqlite3.connect(str(path)) as rconn:
273
+ row = rconn.execute(
274
+ "SELECT credentials_ref FROM backup_destinations WHERE id = ?",
275
+ (dest_id,),
276
+ ).fetchone()
277
+ creds_ref = row[0] if row else None
278
+ except Exception:
279
+ pass
280
+ if creds_ref:
281
+ _delete_credential(creds_ref)
282
+ with memory_write(path) as conn:
283
+ conn.execute("DELETE FROM backup_destinations WHERE id = ?", (dest_id,))
280
284
  return True
281
285
  except Exception as exc:
282
286
  logger.error("Failed to remove destination %s: %s", dest_id, exc)
283
287
  return False
284
- finally:
285
- conn.close()
286
288
 
287
289
 
288
290
  def update_sync_status(
@@ -293,18 +295,15 @@ def update_sync_status(
293
295
  ) -> None:
294
296
  """Update the sync status of a destination."""
295
297
  path = db_path or _default_db_path()
296
- conn = sqlite3.connect(str(path))
297
298
  try:
298
- conn.execute(
299
- "UPDATE backup_destinations SET last_sync_at = ?, "
300
- "last_sync_status = ?, last_sync_error = ? WHERE id = ?",
301
- (datetime.now(UTC).isoformat(), status, error, dest_id),
302
- )
303
- conn.commit()
299
+ with memory_write(path) as conn:
300
+ conn.execute(
301
+ "UPDATE backup_destinations SET last_sync_at = ?, "
302
+ "last_sync_status = ?, last_sync_error = ? WHERE id = ?",
303
+ (datetime.now(UTC).isoformat(), status, error, dest_id),
304
+ )
304
305
  except Exception:
305
306
  pass
306
- finally:
307
- conn.close()
308
307
 
309
308
 
310
309
  # ---------------------------------------------------------------------------