superlocalmemory 3.8.0 → 3.8.2

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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  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 +2 -2
  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 +3 -5
  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 +2 -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 +3 -5
  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 +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -8,22 +8,155 @@ Routes:
8
8
  Cloud: /api/backup/destinations, /api/backup/connect/github, /api/backup/connect/gdrive,
9
9
  /api/backup/disconnect/{id}, /api/backup/sync, /api/backup/export
10
10
  """
11
- import logging
12
11
  import gzip
13
12
  import hashlib
13
+ import hmac
14
+ import html as _html
15
+ import logging
16
+ import secrets
14
17
  import shutil
18
+ import tempfile
19
+ import threading
20
+ import time
15
21
  import urllib.parse
16
22
 
17
23
  from fastapi import APIRouter, HTTPException, Request
18
24
  from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
19
25
  from pydantic import BaseModel, Field
20
- from typing import Optional
26
+ from starlette.background import BackgroundTask
21
27
 
22
- from .helpers import BackupConfigRequest, DB_PATH, MEMORY_DIR
28
+ from .helpers import DB_PATH, MEMORY_DIR, BackupConfigRequest
23
29
 
24
30
  logger = logging.getLogger("superlocalmemory.routes.backup")
25
31
  router = APIRouter()
26
32
 
33
+ _OAUTH_STATE_TTL_SECONDS = 10 * 60
34
+ _OAUTH_STATE_LOCK = threading.Lock()
35
+ _OAUTH_STATES: dict[str, dict[str, object]] = {}
36
+
37
+
38
+ def _require_read(request: Request) -> None:
39
+ """Require READ on the active profile for backup metadata."""
40
+ from superlocalmemory.access.rbac import Permission
41
+ from superlocalmemory.server.rbac_enforce import require_permission
42
+
43
+ require_permission(request, Permission.READ)
44
+
45
+
46
+ def _require_manage(request: Request) -> dict:
47
+ """Require MANAGE for backup configuration, creation, and cloud access."""
48
+ from superlocalmemory.server.rbac_enforce import require_manage
49
+
50
+ return require_manage(request)
51
+
52
+
53
+ def _require_oauth_start(request: Request) -> None:
54
+ """Allow OAuth initiation from the exact dashboard or an authenticated admin."""
55
+ from superlocalmemory.server.origin import origin_is_daemon
56
+
57
+ fetch_site = request.headers.get("sec-fetch-site", "").lower()
58
+ if fetch_site == "cross-site":
59
+ raise HTTPException(
60
+ status_code=403,
61
+ detail="Cross-site OAuth initiation is not allowed.",
62
+ )
63
+ principal = _require_manage(request)
64
+ origin = request.headers.get("origin", "")
65
+ if origin:
66
+ descriptor = getattr(
67
+ getattr(request.app, "state", None),
68
+ "daemon_descriptor",
69
+ None,
70
+ )
71
+ daemon_port = (
72
+ getattr(descriptor, "port", None)
73
+ or request.url.port
74
+ or 8765
75
+ )
76
+ if not origin_is_daemon(origin, port=int(daemon_port)):
77
+ raise HTTPException(
78
+ status_code=403,
79
+ detail="OAuth initiation requires the local dashboard origin.",
80
+ )
81
+ elif fetch_site and fetch_site != "same-origin":
82
+ # Browser top-level navigations commonly omit Origin. Sec-Fetch-Site
83
+ # still distinguishes this daemon's own page (same-origin) from a
84
+ # different localhost port (same-site).
85
+ raise HTTPException(
86
+ status_code=403,
87
+ detail="OAuth initiation requires the local dashboard origin.",
88
+ )
89
+
90
+ host = request.client.host if request.client else ""
91
+ is_loopback = host in {"127.0.0.1", "::1", "localhost", "testclient"}
92
+ if not is_loopback and principal.get("kind") != "user":
93
+ raise HTTPException(
94
+ status_code=403,
95
+ detail=(
96
+ "OAuth connections must start from the local dashboard or an "
97
+ "authenticated admin session."
98
+ ),
99
+ )
100
+
101
+
102
+ def _oauth_context(request: Request) -> str:
103
+ """Fingerprint the local browser/session that initiated an OAuth flow.
104
+
105
+ OAuth callbacks cannot carry the install-token header, so the random state
106
+ is bound to stable browser context available on both the start and callback:
107
+ loopback client address, dashboard user session (when present), and user
108
+ agent. Only the digest is retained in memory.
109
+ """
110
+ client_host = request.client.host if request.client else ""
111
+ session = request.headers.get("X-SLM-User-Session", "")
112
+ if not session:
113
+ session = request.cookies.get("slm_session", "")
114
+ user_agent = request.headers.get("user-agent", "")
115
+ material = "\x00".join((client_host, session, user_agent))
116
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
117
+
118
+
119
+ def _issue_oauth_state(provider: str, request: Request) -> str:
120
+ """Create a cryptographically random, expiring, provider-bound state."""
121
+ now = time.monotonic()
122
+ state = secrets.token_urlsafe(32)
123
+ record = {
124
+ "provider": provider,
125
+ "context": _oauth_context(request),
126
+ "expires_at": now + _OAUTH_STATE_TTL_SECONDS,
127
+ }
128
+ with _OAUTH_STATE_LOCK:
129
+ expired = [
130
+ key for key, value in _OAUTH_STATES.items()
131
+ if float(value.get("expires_at", 0)) <= now
132
+ ]
133
+ for key in expired:
134
+ _OAUTH_STATES.pop(key, None)
135
+ _OAUTH_STATES[state] = record
136
+ return state
137
+
138
+
139
+ def _consume_oauth_state(state: str, provider: str, request: Request) -> bool:
140
+ """Atomically consume and validate OAuth state.
141
+
142
+ The pop happens under the lock, so parallel/replayed callbacks cannot both
143
+ exchange the same authorization code. A mismatched provider or browser
144
+ context fails closed and the suspicious state cannot be retried.
145
+ """
146
+ if not state:
147
+ return False
148
+ with _OAUTH_STATE_LOCK:
149
+ record = _OAUTH_STATES.pop(state, None)
150
+ if not record or float(record.get("expires_at", 0)) <= time.monotonic():
151
+ return False
152
+ return (
153
+ hmac.compare_digest(str(record.get("provider", "")), provider)
154
+ and hmac.compare_digest(
155
+ str(record.get("context", "")),
156
+ _oauth_context(request),
157
+ )
158
+ )
159
+
27
160
 
28
161
  def _internal_error(detail: str = "Internal server error") -> HTTPException:
29
162
  """SEC-H-02: log full traceback server-side; return a generic message to the client."""
@@ -42,9 +175,11 @@ except ImportError:
42
175
 
43
176
  try:
44
177
  from superlocalmemory.infra.cloud_backup import (
45
- get_destinations, add_destination, remove_destination,
46
- connect_github, connect_google_drive,
47
- sync_all_destinations, update_sync_status,
178
+ connect_github,
179
+ connect_google_drive,
180
+ get_destinations,
181
+ remove_destination,
182
+ sync_all_destinations,
48
183
  )
49
184
  CLOUD_AVAILABLE = True
50
185
  except ImportError:
@@ -74,8 +209,9 @@ class GDriveClientConfig(BaseModel):
74
209
  # ---- Local backup routes (existing) ---------------------------------------
75
210
 
76
211
  @router.get("/api/backup/status")
77
- async def backup_status():
212
+ def backup_status(request: Request):
78
213
  """Get auto-backup system status + cloud destinations."""
214
+ _require_read(request)
79
215
  if not BACKUP_AVAILABLE:
80
216
  return {"status": "not_implemented", "message": "Backup module not available"}
81
217
  try:
@@ -91,8 +227,9 @@ async def backup_status():
91
227
 
92
228
 
93
229
  @router.post("/api/backup/create")
94
- async def backup_create():
230
+ def backup_create(request: Request):
95
231
  """Create a manual backup immediately."""
232
+ _require_manage(request)
96
233
  if not BACKUP_AVAILABLE:
97
234
  return {"success": False, "message": "Backup module not available"}
98
235
  try:
@@ -110,16 +247,17 @@ async def backup_create():
110
247
 
111
248
 
112
249
  @router.post("/api/backup/configure")
113
- async def backup_configure(request: BackupConfigRequest):
250
+ def backup_configure(request: Request, payload: BackupConfigRequest):
114
251
  """Update auto-backup configuration."""
252
+ _require_manage(request)
115
253
  if not BACKUP_AVAILABLE:
116
254
  return {"success": False, "message": "Backup module not available"}
117
255
  try:
118
256
  manager = _get_backup_manager()
119
257
  result = manager.configure(
120
- interval_hours=request.interval_hours,
121
- max_backups=request.max_backups,
122
- enabled=request.enabled,
258
+ interval_hours=payload.interval_hours,
259
+ max_backups=payload.max_backups,
260
+ enabled=payload.enabled,
123
261
  )
124
262
  return {"success": True, "message": "Backup configuration updated", "status": result}
125
263
  except Exception:
@@ -127,8 +265,9 @@ async def backup_configure(request: BackupConfigRequest):
127
265
 
128
266
 
129
267
  @router.get("/api/backup/list")
130
- async def backup_list(request: Request):
268
+ def backup_list(request: Request):
131
269
  """List all available backups."""
270
+ _require_read(request)
132
271
  if not BACKUP_AVAILABLE:
133
272
  return {"backups": [], "count": 0, "message": "Backup module not available"}
134
273
  # This GET is not covered by the mutation middleware. The local machine
@@ -151,49 +290,54 @@ async def backup_list(request: Request):
151
290
  # ---- Cloud destination routes (v3.4.10) -----------------------------------
152
291
 
153
292
  @router.get("/api/backup/destinations")
154
- async def list_destinations():
293
+ def list_destinations(request: Request):
155
294
  """List all configured cloud backup destinations."""
295
+ _require_read(request)
156
296
  if not CLOUD_AVAILABLE:
157
297
  return {"destinations": [], "cloud_available": False}
158
298
  return {"destinations": get_destinations(DB_PATH), "cloud_available": True}
159
299
 
160
300
 
161
301
  @router.post("/api/backup/connect/github")
162
- async def connect_github_route(request: GitHubConnectRequest):
302
+ def connect_github_route(request: Request, payload: GitHubConnectRequest):
163
303
  """Connect GitHub as a backup destination using PAT."""
304
+ _require_manage(request)
164
305
  if not CLOUD_AVAILABLE:
165
306
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
166
- result = connect_github(request.pat, request.repo_name)
307
+ result = connect_github(payload.pat, payload.repo_name)
167
308
  if "error" in result:
168
309
  raise HTTPException(status_code=400, detail=result["error"])
169
310
  return result
170
311
 
171
312
 
172
313
  @router.post("/api/backup/connect/gdrive/config")
173
- async def configure_gdrive_client(request: GDriveClientConfig):
314
+ def configure_gdrive_client(request: Request, payload: GDriveClientConfig):
174
315
  """Store Google OAuth client credentials (one-time setup)."""
316
+ _require_manage(request)
175
317
  if not CLOUD_AVAILABLE:
176
318
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
177
319
  from superlocalmemory.infra.cloud_backup import _store_credential
178
- _store_credential("gdrive_client_id", request.client_id)
179
- _store_credential("gdrive_client_secret", request.client_secret)
320
+ _store_credential("gdrive_client_id", payload.client_id)
321
+ _store_credential("gdrive_client_secret", payload.client_secret)
180
322
  return {"success": True, "message": "Google OAuth client configured"}
181
323
 
182
324
 
183
325
  @router.post("/api/backup/connect/gdrive")
184
- async def connect_gdrive_route(request: GDriveConnectRequest):
326
+ def connect_gdrive_route(request: Request, payload: GDriveConnectRequest):
185
327
  """Complete Google Drive OAuth2 flow with authorization code."""
328
+ _require_manage(request)
186
329
  if not CLOUD_AVAILABLE:
187
330
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
188
- result = connect_google_drive(request.auth_code, request.redirect_uri)
331
+ result = connect_google_drive(payload.auth_code, payload.redirect_uri)
189
332
  if "error" in result:
190
333
  raise HTTPException(status_code=400, detail=result["error"])
191
334
  return result
192
335
 
193
336
 
194
337
  @router.delete("/api/backup/disconnect/{dest_id}")
195
- async def disconnect_destination(dest_id: str):
338
+ def disconnect_destination(request: Request, dest_id: str):
196
339
  """Remove a cloud backup destination."""
340
+ _require_manage(request)
197
341
  if not CLOUD_AVAILABLE:
198
342
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
199
343
  ok = remove_destination(dest_id, DB_PATH)
@@ -203,16 +347,16 @@ async def disconnect_destination(dest_id: str):
203
347
 
204
348
 
205
349
  @router.post("/api/backup/sync")
206
- async def sync_cloud():
350
+ def sync_cloud(request: Request):
207
351
  """Manually trigger sync to all cloud destinations.
208
352
 
209
353
  Runs the upload in a background thread so it doesn't block the
210
354
  dashboard. Returns immediately with status 'syncing'. The actual
211
355
  upload status is reflected in the destination's last_sync_status.
212
356
  """
213
- import asyncio
214
357
  import threading
215
358
 
359
+ _require_manage(request)
216
360
  if not CLOUD_AVAILABLE:
217
361
  raise HTTPException(status_code=501, detail="Cloud backup module not available")
218
362
  if not BACKUP_AVAILABLE:
@@ -237,25 +381,28 @@ async def sync_cloud():
237
381
  return {
238
382
  "success": True,
239
383
  "backup": filename,
240
- "sync": {"status": "syncing", "message": "Upload started in background. Check destination status for progress."},
384
+ "sync": {
385
+ "status": "syncing",
386
+ "message": (
387
+ "Upload started in background. Check destination status for progress."
388
+ ),
389
+ },
241
390
  }
242
391
 
243
392
 
244
393
  # ---- Export / Download route (v3.4.10) ------------------------------------
245
394
 
246
- @router.get("/api/backup/export")
247
- async def export_backup(request: Request):
248
- """Create and download a compressed backup archive."""
395
+ @router.post("/api/backup/export")
396
+ def export_backup(request: Request):
397
+ """Create and download a compressed backup archive.
398
+
399
+ Export is a credentialed mutation because it creates a local snapshot.
400
+ The compressed transport file is temporary and is removed after the
401
+ response completes; the normal retention policy owns the snapshot itself.
402
+ """
403
+ _require_manage(request)
249
404
  if not BACKUP_AVAILABLE:
250
405
  raise HTTPException(status_code=501, detail="Backup module not available")
251
- # Full-database download. This GET is not covered by the mutation
252
- # middleware and is triggered by a top-level navigation (window.location),
253
- # which cannot carry a custom credential header — so it uses the
254
- # loopback-trusted mutation-actor boundary: the local owner may export,
255
- # a non-loopback caller without a credential fails closed.
256
- from superlocalmemory.server.write_identity import require_http_mutation_actor
257
- require_http_mutation_actor(request, getattr(request.app.state, "daemon_descriptor", None),
258
- actor_kind="backup-export")
259
406
 
260
407
  manager = _get_backup_manager()
261
408
  filename = manager.create_backup(label="export")
@@ -266,16 +413,28 @@ async def export_backup(request: Request):
266
413
  if not backup_path.exists():
267
414
  raise HTTPException(status_code=500, detail="Backup file not found")
268
415
 
269
- # Compress for download
270
- gz_path = backup_path.with_suffix(".db.gz")
271
- with open(backup_path, "rb") as f_in:
272
- with gzip.open(gz_path, "wb") as f_out:
273
- shutil.copyfileobj(f_in, f_out)
416
+ # Compress into a one-response transport file. Never retain another full
417
+ # copy beside the bounded snapshot set.
418
+ with tempfile.NamedTemporaryFile(
419
+ prefix="slm-export-",
420
+ suffix=".db.gz",
421
+ dir=backup_path.parent,
422
+ delete=False,
423
+ ) as temp_file:
424
+ gz_path = backup_path.parent / temp_file.name
425
+ try:
426
+ with open(backup_path, "rb") as f_in:
427
+ with gzip.open(gz_path, "wb") as f_out:
428
+ shutil.copyfileobj(f_in, f_out)
429
+ except Exception:
430
+ gz_path.unlink(missing_ok=True)
431
+ raise
274
432
 
275
433
  return FileResponse(
276
434
  path=str(gz_path),
277
435
  media_type="application/gzip",
278
- filename=gz_path.name,
436
+ filename=f"{backup_path.name}.gz",
437
+ background=BackgroundTask(gz_path.unlink, missing_ok=True),
279
438
  )
280
439
 
281
440
 
@@ -332,7 +491,6 @@ p {{ color: #999; margin: 0 0 20px; font-size: 13px; }}
332
491
  # ``str.format`` doesn't escape HTML, so a hostile OAuth callback can
333
492
  # inject ``<script>`` into these pages. These helpers HTML-escape every
334
493
  # interpolated value before emitting the template.
335
- import html as _html
336
494
 
337
495
 
338
496
  def _oauth_error_page(icon: str, error: str) -> str:
@@ -353,8 +511,9 @@ def _oauth_success_page(icon: str, title: str, message: str) -> str:
353
511
  # ---- Google OAuth SSO Flow ------------------------------------------------
354
512
 
355
513
  @router.get("/api/backup/oauth/google/start")
356
- async def google_oauth_start(request: Request):
514
+ def google_oauth_start(request: Request):
357
515
  """Start Google OAuth2 flow — redirects to Google's login page."""
516
+ _require_oauth_start(request)
358
517
  if not CLOUD_AVAILABLE:
359
518
  return HTMLResponse(_oauth_error_page(icon="&#x26A0;", error="Cloud backup module not available"))
360
519
 
@@ -422,7 +581,7 @@ a { color: #00D4AA; }
422
581
  </div>
423
582
 
424
583
  <p style="color:#555;font-size:11px;margin-top:16px;">
425
- Your credentials are stored in your OS keychain (macOS Keychain / Windows Credential Locker) &mdash; never in plaintext.
584
+ Credentials use your OS credential store when available, with a permission-restricted local fallback.
426
585
  Full guide: <a href="https://github.com/qualixar/superlocalmemory/wiki/Cloud-Backup#google-drive-backup" target="_blank">Cloud Backup Wiki</a>
427
586
  </p>
428
587
  </div>
@@ -432,20 +591,29 @@ async function saveAndConnect() {
432
591
  var csec = document.getElementById('csec').value.trim();
433
592
  if (!cid || !csec) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Both fields required</span>'; return; }
434
593
  document.getElementById('saveBtn').disabled = true;
435
- document.getElementById('status').innerHTML = '<span style="color:#999">Saving...</span>';
594
+ var status = document.getElementById('status');
595
+ status.style.color = '#999';
596
+ status.textContent = 'Saving...';
436
597
  try {
598
+ var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
599
+ if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
600
+ var tokenData = await tokenResp.json();
601
+ if (!tokenData.token) throw new Error('Could not authorize this dashboard');
437
602
  var resp = await fetch('/api/backup/connect/gdrive/config', {
438
- method: 'POST', headers: {'Content-Type': 'application/json'},
603
+ method: 'POST', credentials: 'same-origin',
604
+ headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
439
605
  body: JSON.stringify({client_id: cid, client_secret: csec})
440
606
  });
441
607
  if (resp.ok) {
442
608
  window.location.href = '/api/backup/oauth/google/start';
443
609
  } else {
444
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Failed to save</span>';
610
+ status.style.color = '#ff4757';
611
+ status.textContent = 'Failed to save';
445
612
  document.getElementById('saveBtn').disabled = false;
446
613
  }
447
614
  } catch(e) {
448
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Error: ' + e.message + '</span>';
615
+ status.style.color = '#ff4757';
616
+ status.textContent = 'Error: ' + e.message;
449
617
  document.getElementById('saveBtn').disabled = false;
450
618
  }
451
619
  }
@@ -454,22 +622,37 @@ async function saveAndConnect() {
454
622
  # Build the Google OAuth URL
455
623
  base_url = str(request.base_url).rstrip("/")
456
624
  redirect_uri = f"{base_url}/api/backup/oauth/google/callback"
625
+ state = _issue_oauth_state("google", request)
457
626
 
458
627
  params = urllib.parse.urlencode({
459
628
  "client_id": client_id,
460
629
  "redirect_uri": redirect_uri,
461
630
  "response_type": "code",
462
- "scope": "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
631
+ "scope": "openid https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email",
463
632
  "access_type": "offline",
464
633
  "prompt": "consent",
634
+ "state": state,
465
635
  })
466
636
 
467
637
  return RedirectResponse(f"https://accounts.google.com/o/oauth2/v2/auth?{params}")
468
638
 
469
639
 
470
640
  @router.get("/api/backup/oauth/google/callback")
471
- async def google_oauth_callback(request: Request, code: str = "", error: str = ""):
641
+ def google_oauth_callback(
642
+ request: Request,
643
+ code: str = "",
644
+ error: str = "",
645
+ state: str = "",
646
+ ):
472
647
  """Google OAuth2 callback — exchanges code for tokens."""
648
+ if not _consume_oauth_state(state, "google", request):
649
+ return HTMLResponse(
650
+ _oauth_error_page(
651
+ icon="&#x274C;",
652
+ error="Invalid, expired, or already-used OAuth state. Start the connection again.",
653
+ ),
654
+ status_code=400,
655
+ )
473
656
  if error:
474
657
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=f"Google denied access: {error}"))
475
658
 
@@ -487,7 +670,7 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
487
670
  return HTMLResponse(_oauth_success_page(
488
671
  icon="&#x2601;&#xFE0F;",
489
672
  title="Google Drive Connected!",
490
- message=f"Signed in as {result.get('email', 'unknown')}. Your memories will be backed up automatically."
673
+ message=f"Signed in as {result.get('email', 'unknown')}. The destination is configured; sync status will appear after an upload."
491
674
  ))
492
675
 
493
676
 
@@ -497,8 +680,9 @@ async def google_oauth_callback(request: Request, code: str = "", error: str = "
497
680
  # otherwise fall back to Device Flow with a nice UI.
498
681
 
499
682
  @router.get("/api/backup/oauth/github/start")
500
- async def github_oauth_start(request: Request):
683
+ def github_oauth_start(request: Request):
501
684
  """Start GitHub OAuth flow."""
685
+ _require_oauth_start(request)
502
686
  if not CLOUD_AVAILABLE:
503
687
  return HTMLResponse(_oauth_error_page(icon="&#x26A0;", error="Cloud backup module not available"))
504
688
 
@@ -511,12 +695,13 @@ async def github_oauth_start(request: Request):
511
695
  # Full OAuth Web Flow — browser redirects to GitHub login
512
696
  base_url = str(request.base_url).rstrip("/")
513
697
  redirect_uri = f"{base_url}/api/backup/oauth/github/callback"
698
+ state = _issue_oauth_state("github", request)
514
699
 
515
700
  params = urllib.parse.urlencode({
516
701
  "client_id": gh_client_id,
517
702
  "redirect_uri": redirect_uri,
518
703
  "scope": "repo",
519
- "state": hashlib.sha256(base_url.encode()).hexdigest()[:16],
704
+ "state": state,
520
705
  })
521
706
  return RedirectResponse(f"https://github.com/login/oauth/authorize?{params}")
522
707
 
@@ -558,38 +743,61 @@ a { color: #00D4AA; }
558
743
 
559
744
  <p class="hint">
560
745
  Need a token? <a href="https://github.com/settings/tokens/new?scopes=repo&description=SLM+Backup" target="_blank">Create one here</a> (select <code>repo</code> scope).
561
- Your token is stored securely in your OS keychain.
746
+ Your token uses the OS credential store when available, with a permission-restricted local fallback.
562
747
  </p>
563
748
  </div>
564
749
  <script>
565
750
  async function doConnect() {
566
751
  var pat = document.getElementById('pat').value.trim();
567
752
  var repo = document.getElementById('repo').value.trim();
568
- if (!pat) { document.getElementById('status').innerHTML = '<span style="color:#ff4757">Token required</span>'; return; }
753
+ var status = document.getElementById('status');
754
+ if (!pat) { status.style.color = '#ff4757'; status.textContent = 'Token required'; return; }
569
755
 
570
756
  var btn = document.getElementById('connectBtn');
571
757
  btn.disabled = true; btn.textContent = 'Connecting...';
572
- document.getElementById('status').innerHTML = '<span style="color:#999">Verifying token and creating repo...</span>';
758
+ status.style.color = '#999';
759
+ status.textContent = 'Verifying token and creating repo...';
573
760
 
574
761
  try {
762
+ var tokenResp = await fetch('/internal/token', {credentials: 'same-origin'});
763
+ if (!tokenResp.ok) throw new Error('Could not authorize this dashboard');
764
+ var tokenData = await tokenResp.json();
765
+ if (!tokenData.token) throw new Error('Could not authorize this dashboard');
575
766
  var resp = await fetch('/api/backup/connect/github', {
576
767
  method: 'POST',
577
- headers: {'Content-Type': 'application/json'},
768
+ credentials: 'same-origin',
769
+ headers: {'Content-Type': 'application/json', 'X-Install-Token': tokenData.token},
578
770
  body: JSON.stringify({pat: pat, repo_name: repo || 'slm-backup'})
579
771
  });
580
772
  var data = await resp.json();
581
773
  if (resp.ok) {
582
- document.body.innerHTML = '<div class="card" style="text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;">' +
583
- '<div style="font-size:48px;margin-bottom:16px;">&#x2705;</div>' +
584
- '<h2 style="color:#00D4AA;margin:0 0 8px;">GitHub Connected!</h2>' +
585
- '<p style="color:#999;margin:0 0 20px;">Repository: ' + (data.repo || repo) + '</p>' +
586
- '<button class="btn" style="background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;" onclick="window.close()">Close Window</button></div>';
774
+ var card = document.createElement('div');
775
+ card.className = 'card';
776
+ card.style.cssText = 'text-align:center;background:rgba(255,255,255,0.05);border:1px solid rgba(0,212,170,0.3);border-radius:16px;padding:40px;max-width:400px;';
777
+ var icon = document.createElement('div');
778
+ icon.style.cssText = 'font-size:48px;margin-bottom:16px;';
779
+ icon.textContent = '\u2705';
780
+ var title = document.createElement('h2');
781
+ title.style.cssText = 'color:#00D4AA;margin:0 0 8px;';
782
+ title.textContent = 'GitHub Connected!';
783
+ var repoText = document.createElement('p');
784
+ repoText.style.cssText = 'color:#999;margin:0 0 20px;';
785
+ repoText.textContent = 'Repository: ' + (data.repo || repo);
786
+ var closeBtn = document.createElement('button');
787
+ closeBtn.className = 'btn';
788
+ closeBtn.style.cssText = 'background:#00D4AA;color:#0a0a0f;border:none;padding:10px 24px;border-radius:8px;cursor:pointer;font-weight:600;';
789
+ closeBtn.textContent = 'Close Window';
790
+ closeBtn.addEventListener('click', function () { window.close(); });
791
+ card.append(icon, title, repoText, closeBtn);
792
+ document.body.replaceChildren(card);
587
793
  } else {
588
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">' + (data.detail || 'Connection failed') + '</span>';
794
+ status.style.color = '#ff4757';
795
+ status.textContent = data.detail || 'Connection failed';
589
796
  btn.disabled = false; btn.textContent = 'Connect';
590
797
  }
591
798
  } catch(e) {
592
- document.getElementById('status').innerHTML = '<span style="color:#ff4757">Connection failed</span>';
799
+ status.style.color = '#ff4757';
800
+ status.textContent = 'Connection failed';
593
801
  btn.disabled = false; btn.textContent = 'Connect';
594
802
  }
595
803
  }
@@ -597,17 +805,31 @@ async function doConnect() {
597
805
 
598
806
 
599
807
  @router.get("/api/backup/oauth/github/callback")
600
- async def github_oauth_callback(request: Request, code: str = "", error: str = ""):
808
+ def github_oauth_callback(
809
+ request: Request,
810
+ code: str = "",
811
+ error: str = "",
812
+ state: str = "",
813
+ ):
601
814
  """GitHub OAuth callback — exchanges code for access token."""
815
+ if not _consume_oauth_state(state, "github", request):
816
+ return HTMLResponse(
817
+ _oauth_error_page(
818
+ icon="&#x274C;",
819
+ error="Invalid, expired, or already-used OAuth state. Start the connection again.",
820
+ ),
821
+ status_code=400,
822
+ )
602
823
  if error:
603
824
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=f"GitHub denied access: {error}"))
604
825
 
605
826
  if not code:
606
827
  return HTMLResponse(_oauth_error_page(icon="&#x274C;", error="No authorization code received"))
607
828
 
608
- from superlocalmemory.infra.cloud_backup import _get_credential, _store_credential
609
829
  import httpx
610
830
 
831
+ from superlocalmemory.infra.cloud_backup import _get_credential
832
+
611
833
  gh_client_id = _get_credential("github_client_id")
612
834
  gh_client_secret = _get_credential("github_client_secret")
613
835
 
@@ -635,8 +857,9 @@ async def github_oauth_callback(request: Request, code: str = "", error: str = "
635
857
  return HTMLResponse(_oauth_success_page(
636
858
  icon="&#x2705;",
637
859
  title="GitHub Connected!",
638
- message=f"Repository: {result.get('repo', 'slm-backup')}. Your memories will be backed up automatically."
860
+ message=f"Repository: {result.get('repo', 'slm-backup')}. The destination is configured; sync status will appear after an upload."
639
861
  ))
640
862
 
641
- except Exception as exc:
642
- return HTMLResponse(_oauth_error_page(icon="&#x274C;", error=str(exc)))
863
+ except Exception:
864
+ logger.exception("GitHub OAuth callback failed")
865
+ return HTMLResponse(_oauth_error_page(icon="&#x274C;", error="GitHub connection failed"))