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.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- 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 +2 -2
- 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 +3 -5
- 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 +2 -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 +3 -5
- 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 +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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
|
|
26
|
+
from starlette.background import BackgroundTask
|
|
21
27
|
|
|
22
|
-
from .helpers import
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
121
|
-
max_backups=
|
|
122
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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",
|
|
179
|
-
_store_credential("gdrive_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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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": {
|
|
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.
|
|
247
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
with
|
|
272
|
-
|
|
273
|
-
|
|
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=
|
|
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
|
-
|
|
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="⚠", 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
|
-
|
|
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')
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="❌",
|
|
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="❌", 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="☁️",
|
|
489
672
|
title="Google Drive Connected!",
|
|
490
|
-
message=f"Signed in as {result.get('email', 'unknown')}.
|
|
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
|
-
|
|
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="⚠", 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":
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="❌",
|
|
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="❌", error=f"GitHub denied access: {error}"))
|
|
604
825
|
|
|
605
826
|
if not code:
|
|
606
827
|
return HTMLResponse(_oauth_error_page(icon="❌", 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="✅",
|
|
637
859
|
title="GitHub Connected!",
|
|
638
|
-
message=f"Repository: {result.get('repo', 'slm-backup')}.
|
|
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
|
|
642
|
-
|
|
863
|
+
except Exception:
|
|
864
|
+
logger.exception("GitHub OAuth callback failed")
|
|
865
|
+
return HTMLResponse(_oauth_error_page(icon="❌", error="GitHub connection failed"))
|