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.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
192
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
"
|
|
207
|
-
|
|
208
|
-
"
|
|
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 —
|
|
307
|
-
|
|
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
|
-
#
|
|
320
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|