loop-memory 0.4.0__py3-none-any.whl
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.
- loop_memory/__init__.py +62 -0
- loop_memory/backends/__init__.py +13 -0
- loop_memory/backends/embedding.py +82 -0
- loop_memory/backends/sentence_embedder.py +30 -0
- loop_memory/backends/vector_store.py +139 -0
- loop_memory/cli/__init__.py +0 -0
- loop_memory/cli/_common.py +68 -0
- loop_memory/cli/commands/__init__.py +13 -0
- loop_memory/cli/commands/cognitive.py +205 -0
- loop_memory/cli/commands/diag.py +346 -0
- loop_memory/cli/commands/graph.py +21 -0
- loop_memory/cli/commands/hooks.py +212 -0
- loop_memory/cli/commands/read.py +362 -0
- loop_memory/cli/commands/serve.py +147 -0
- loop_memory/cli/commands/write.py +138 -0
- loop_memory/cli/main.py +115 -0
- loop_memory/engine/__init__.py +0 -0
- loop_memory/engine/loop.py +247 -0
- loop_memory/engine/reflect.py +89 -0
- loop_memory/examples/__init__.py +0 -0
- loop_memory/examples/demo.py +39 -0
- loop_memory/export/__init__.py +39 -0
- loop_memory/export/memory_md.py +629 -0
- loop_memory/graph/__init__.py +0 -0
- loop_memory/graph/build.py +259 -0
- loop_memory/graph/extract.py +197 -0
- loop_memory/ingest/__init__.py +0 -0
- loop_memory/ingest/loader.py +782 -0
- loop_memory/ingest/pipeline.py +458 -0
- loop_memory/jobs/__init__.py +0 -0
- loop_memory/jobs/cognitive.py +353 -0
- loop_memory/jobs/compact.py +371 -0
- loop_memory/jobs/consolidate.py +95 -0
- loop_memory/jobs/contradiction.py +281 -0
- loop_memory/jobs/evolution.py +2021 -0
- loop_memory/jobs/graph.py +395 -0
- loop_memory/jobs/llm_compact_pass.py +24 -0
- loop_memory/jobs/llm_consolidate.py +980 -0
- loop_memory/jobs/scheduler.py +495 -0
- loop_memory/llm/__init__.py +0 -0
- loop_memory/llm/base.py +80 -0
- loop_memory/llm/openai_adapter.py +31 -0
- loop_memory/llm/providers.py +517 -0
- loop_memory/mcp/__init__.py +804 -0
- loop_memory/memory/__init__.py +0 -0
- loop_memory/memory/types.py +199 -0
- loop_memory/privacy/__init__.py +22 -0
- loop_memory/privacy/private.py +46 -0
- loop_memory/privacy/redact.py +188 -0
- loop_memory/py.typed +0 -0
- loop_memory/sdk.py +875 -0
- loop_memory/sdk_extensions.py +384 -0
- loop_memory/security/__init__.py +20 -0
- loop_memory/security/secrets.py +464 -0
- loop_memory/serve/__init__.py +0 -0
- loop_memory/serve/app.py +506 -0
- loop_memory/serve/handlers.py +316 -0
- loop_memory/serve/routes/_shared.py +59 -0
- loop_memory/serve/routes/admin.py +970 -0
- loop_memory/serve/routes/cognitive.py +64 -0
- loop_memory/serve/routes/export.py +65 -0
- loop_memory/serve/routes/graph.py +101 -0
- loop_memory/serve/routes/insights.py +702 -0
- loop_memory/serve/routes/memories.py +435 -0
- loop_memory/serve/routes/sessions.py +75 -0
- loop_memory/serve/routes/system.py +493 -0
- loop_memory/serve/routes/wiki.py +812 -0
- loop_memory/serve/static/__init__.py +0 -0
- loop_memory/serve/static/index.html +15 -0
- loop_memory/serve/watcher.py +451 -0
- loop_memory/storage/__init__.py +5 -0
- loop_memory/storage/retrieval.py +365 -0
- loop_memory/storage/sqlite_store.py +3627 -0
- loop_memory/wiki/__init__.py +41 -0
- loop_memory/wiki/backfill.py +143 -0
- loop_memory/wiki/classifier.py +238 -0
- loop_memory/wiki/prompts.py +295 -0
- loop_memory/wiki/scope.py +227 -0
- loop_memory-0.4.0.dist-info/METADATA +627 -0
- loop_memory-0.4.0.dist-info/RECORD +84 -0
- loop_memory-0.4.0.dist-info/WHEEL +5 -0
- loop_memory-0.4.0.dist-info/entry_points.txt +2 -0
- loop_memory-0.4.0.dist-info/licenses/LICENSE +21 -0
- loop_memory-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
"""Cross-platform secret storage for API keys.
|
|
2
|
+
|
|
3
|
+
The Loop Memory store keeps *configuration* (provider name, model,
|
|
4
|
+
base URL, schedule) in the regular SQLite ``settings`` table. Secret
|
|
5
|
+
material — API keys, OAuth tokens — never touches the database.
|
|
6
|
+
|
|
7
|
+
Instead, secrets go through a small pluggable ``SecretStore`` with
|
|
8
|
+
platform-native backends:
|
|
9
|
+
|
|
10
|
+
* macOS → Keychain (``security`` CLI)
|
|
11
|
+
* Linux → Secret Service (``secret-tool`` from libsecret)
|
|
12
|
+
* Windows→ Windows Credential Manager (``cmdkey`` / PowerShell)
|
|
13
|
+
* any → a 0600-permission file under
|
|
14
|
+
``$LOOP_MEMORY_DATA_DIR/secrets.json`` (fallback)
|
|
15
|
+
|
|
16
|
+
The "primary" backend is the OS one when available. The fallback file
|
|
17
|
+
is created on demand and warning-logged so a misconfigured CI box
|
|
18
|
+
still works.
|
|
19
|
+
|
|
20
|
+
Each secret is identified by an *account name* (a string), and the
|
|
21
|
+
secret store returns / takes opaque bytes. The settings table only
|
|
22
|
+
needs to remember the account name (e.g. ``llm/openai``), never the
|
|
23
|
+
value.
|
|
24
|
+
|
|
25
|
+
This module has zero third-party deps. ``keyring`` would have been
|
|
26
|
+
the obvious choice but is not installed in the base environment.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import platform
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import tempfile
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SERVICE = "loop_memory"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _account_for(provider: str, key: str = "api_key") -> str:
|
|
47
|
+
"""Stable account name for the (provider, key) pair."""
|
|
48
|
+
return f"llm/{provider}/{key}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run(cmd: list, *, input_bytes: bytes | None = None,
|
|
52
|
+
timeout: float = 5.0) -> tuple[int, str, str]:
|
|
53
|
+
try:
|
|
54
|
+
proc = subprocess.run(
|
|
55
|
+
cmd,
|
|
56
|
+
input=input_bytes,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
check=False,
|
|
60
|
+
)
|
|
61
|
+
return proc.returncode, proc.stdout.decode("utf-8", "replace").strip(), \
|
|
62
|
+
proc.stderr.decode("utf-8", "replace").strip()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return 127, "", f"command not found: {cmd[0]}"
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return 1, "", f"{type(e).__name__}: {e}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# macOS Keychain
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
class MacOSKeychainStore:
|
|
74
|
+
"""Use the ``security`` CLI to talk to the user login keychain."""
|
|
75
|
+
|
|
76
|
+
name = "macos-keychain"
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
if not shutil.which("security"):
|
|
80
|
+
raise RuntimeError("macOS 'security' CLI not available")
|
|
81
|
+
|
|
82
|
+
def get(self, account: str) -> str | None:
|
|
83
|
+
rc, out, err = _run([
|
|
84
|
+
"security", "find-generic-password",
|
|
85
|
+
"-a", account, "-s", SERVICE, "-w",
|
|
86
|
+
])
|
|
87
|
+
if rc == 0 and out:
|
|
88
|
+
return out
|
|
89
|
+
if rc != 0 and "could not be found" not in err.lower() \
|
|
90
|
+
and "SecKeychainSearchCopyNext" not in err:
|
|
91
|
+
log.debug("keychain get %s: rc=%s err=%s", account, rc, err[:200])
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def set(self, account: str, value: str) -> None:
|
|
95
|
+
# -U updates an existing entry. -T /usr/bin/security whitelists
|
|
96
|
+
# only the ``security`` CLI to read the entry, so subsequent
|
|
97
|
+
# reads from this process do not pop a Keychain UI prompt.
|
|
98
|
+
# (Using -T "" would *remove* all trusted apps, which forces
|
|
99
|
+
# a prompt every time.)
|
|
100
|
+
rc, _, err = _run([
|
|
101
|
+
"security", "add-generic-password",
|
|
102
|
+
"-a", account, "-s", SERVICE, "-w", value,
|
|
103
|
+
"-U",
|
|
104
|
+
"-T", "/usr/bin/security",
|
|
105
|
+
])
|
|
106
|
+
if rc != 0:
|
|
107
|
+
# Some macOS versions reject -T. Fall back to -A (allow any
|
|
108
|
+
# application). Still better than plaintext on disk.
|
|
109
|
+
rc2, _, err2 = _run([
|
|
110
|
+
"security", "add-generic-password",
|
|
111
|
+
"-a", account, "-s", SERVICE, "-w", value,
|
|
112
|
+
"-U", "-A",
|
|
113
|
+
])
|
|
114
|
+
if rc2 != 0:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"keychain set failed: {err[:200]!r} / fallback: {err2[:200]!r}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def delete(self, account: str) -> bool:
|
|
120
|
+
rc, _, err = _run([
|
|
121
|
+
"security", "delete-generic-password",
|
|
122
|
+
"-a", account, "-s", SERVICE,
|
|
123
|
+
])
|
|
124
|
+
return rc == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Linux Secret Service (libsecret via secret-tool)
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
class LinuxSecretServiceStore:
|
|
132
|
+
name = "linux-secret-service"
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
if not shutil.which("secret-tool"):
|
|
136
|
+
raise RuntimeError("'secret-tool' not installed (libsecret-tools)")
|
|
137
|
+
|
|
138
|
+
def _lookup(self, account: str) -> str | None:
|
|
139
|
+
rc, out, _ = _run([
|
|
140
|
+
"secret-tool", "lookup", "service", SERVICE, "account", account,
|
|
141
|
+
])
|
|
142
|
+
return out if rc == 0 and out else None
|
|
143
|
+
|
|
144
|
+
def get(self, account: str) -> str | None:
|
|
145
|
+
return self._lookup(account)
|
|
146
|
+
|
|
147
|
+
def set(self, account: str, value: str) -> None:
|
|
148
|
+
rc, _, err = _run([
|
|
149
|
+
"secret-tool", "store",
|
|
150
|
+
"--label", f"Loop Memory - {account}",
|
|
151
|
+
"service", SERVICE, "account", account,
|
|
152
|
+
], input_bytes=value.encode("utf-8"))
|
|
153
|
+
if rc != 0:
|
|
154
|
+
raise RuntimeError(f"secret-tool store failed: {err[:300]}")
|
|
155
|
+
|
|
156
|
+
def delete(self, account: str) -> bool:
|
|
157
|
+
rc, _, err = _run([
|
|
158
|
+
"secret-tool", "clear", "service", SERVICE, "account", account,
|
|
159
|
+
])
|
|
160
|
+
return rc == 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Windows Credential Manager
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
class WindowsCredentialStore:
|
|
168
|
+
"""Uses PowerShell with the ``CredentialManager`` module."""
|
|
169
|
+
|
|
170
|
+
name = "windows-credential"
|
|
171
|
+
|
|
172
|
+
def __init__(self) -> None:
|
|
173
|
+
if platform.system() != "Windows":
|
|
174
|
+
raise RuntimeError("not on Windows")
|
|
175
|
+
if not shutil.which("powershell") and not shutil.which("pwsh"):
|
|
176
|
+
raise RuntimeError("no PowerShell available")
|
|
177
|
+
|
|
178
|
+
def _ps(self) -> list:
|
|
179
|
+
return [shutil.which("powershell") or shutil.which("pwsh")]
|
|
180
|
+
|
|
181
|
+
def get(self, account: str) -> str | None:
|
|
182
|
+
target = f"{SERVICE}/{account}"
|
|
183
|
+
ps = (
|
|
184
|
+
"$c = Get-StoredCredential -Target '" + target + "' -ErrorAction SilentlyContinue; "
|
|
185
|
+
"if ($c) { [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($c.Password)) } "
|
|
186
|
+
"else { '' }"
|
|
187
|
+
)
|
|
188
|
+
rc, out, err = _run(self._ps() + [
|
|
189
|
+
"-NoProfile", "-NonInteractive", "-Command", ps,
|
|
190
|
+
], timeout=10.0)
|
|
191
|
+
if rc != 0:
|
|
192
|
+
log.debug("wincred get failed: %s", err[:200])
|
|
193
|
+
return None
|
|
194
|
+
return out or None
|
|
195
|
+
|
|
196
|
+
def set(self, account: str, value: str) -> None:
|
|
197
|
+
ps = (
|
|
198
|
+
"$pw = ConvertTo-SecureString '" + value.replace("'", "''") + "' -AsPlainText -Force; "
|
|
199
|
+
"New-StoredCredential -Target '" + SERVICE + "/" + account + "' "
|
|
200
|
+
"-SecureString $pw -Type Generic -Persist LocalMachine -ErrorAction SilentlyContinue | Out-Null"
|
|
201
|
+
)
|
|
202
|
+
rc, _, err = _run(self._ps() + [
|
|
203
|
+
"-NoProfile", "-NonInteractive", "-Command", ps,
|
|
204
|
+
], timeout=10.0)
|
|
205
|
+
if rc != 0:
|
|
206
|
+
raise RuntimeError(f"wincred set failed: {err[:300]}")
|
|
207
|
+
|
|
208
|
+
def delete(self, account: str) -> bool:
|
|
209
|
+
ps = "Remove-StoredCredential -Target '" + SERVICE + "/" + account + "' -ErrorAction SilentlyContinue"
|
|
210
|
+
rc, _, _ = _run(self._ps() + [
|
|
211
|
+
"-NoProfile", "-NonInteractive", "-Command", ps,
|
|
212
|
+
], timeout=10.0)
|
|
213
|
+
return rc == 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Encrypted file fallback
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
class FileSecretStore:
|
|
221
|
+
"""A 0600-permission JSON file under LOOP_MEMORY_DATA_DIR.
|
|
222
|
+
|
|
223
|
+
The file is plain JSON — the OS user-account boundary is the only
|
|
224
|
+
protection here. We log a warning when this backend is selected
|
|
225
|
+
so a user on a shared machine knows to upgrade.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
name = "local-file"
|
|
229
|
+
|
|
230
|
+
def __init__(self) -> None:
|
|
231
|
+
base = os.environ.get("LOOP_MEMORY_DATA_DIR") or \
|
|
232
|
+
os.path.join(os.path.expanduser("~"), ".loop_memory")
|
|
233
|
+
self.path = Path(base) / "secrets.json"
|
|
234
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
if not self.path.exists():
|
|
236
|
+
self._write({})
|
|
237
|
+
try:
|
|
238
|
+
os.chmod(self.path, 0o600)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
log.info(
|
|
242
|
+
"local-file secret store at %s (never leaves your machine)",
|
|
243
|
+
self.path,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _read(self) -> dict:
|
|
247
|
+
try:
|
|
248
|
+
return json.loads(self.path.read_text(encoding="utf-8") or "{}")
|
|
249
|
+
except Exception:
|
|
250
|
+
return {}
|
|
251
|
+
|
|
252
|
+
def _write(self, data: dict) -> None:
|
|
253
|
+
# Write atomically: temp file -> rename -> chmod 0o600
|
|
254
|
+
fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), prefix=".secrets.")
|
|
255
|
+
try:
|
|
256
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
257
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
258
|
+
os.chmod(tmp, 0o600)
|
|
259
|
+
os.replace(tmp, self.path)
|
|
260
|
+
except Exception:
|
|
261
|
+
try: os.unlink(tmp)
|
|
262
|
+
except Exception: pass
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
def get(self, account: str) -> str | None:
|
|
266
|
+
d = self._read()
|
|
267
|
+
v = d.get(account)
|
|
268
|
+
return v if v else None
|
|
269
|
+
|
|
270
|
+
def set(self, account: str, value: str) -> None:
|
|
271
|
+
d = self._read()
|
|
272
|
+
d[account] = value
|
|
273
|
+
self._write(d)
|
|
274
|
+
|
|
275
|
+
def delete(self, account: str) -> bool:
|
|
276
|
+
d = self._read()
|
|
277
|
+
if account in d:
|
|
278
|
+
del d[account]
|
|
279
|
+
self._write(d)
|
|
280
|
+
return True
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Factory + facade
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
_BACKEND: object | None = None
|
|
289
|
+
_BACKEND_NAME: str = "none"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _pick_backend():
|
|
293
|
+
"""Pick a backend. Default is the local-encrypted file store.
|
|
294
|
+
|
|
295
|
+
Order of preference (overridable via LOOP_MEMORY_SECRET_BACKEND):
|
|
296
|
+
|
|
297
|
+
1. ``file`` — always-available 0600 JSON under $LOOP_MEMORY_DATA_DIR
|
|
298
|
+
2. ``macos-keychain`` — macOS Keychain via ``security`` CLI
|
|
299
|
+
3. ``linux-secret-service`` — libsecret via ``secret-tool``
|
|
300
|
+
4. ``windows-cred`` — Windows Credential Manager
|
|
301
|
+
|
|
302
|
+
The ``file`` backend is intentionally first because:
|
|
303
|
+
|
|
304
|
+
* it works inside any sandbox / CI / container
|
|
305
|
+
* it never pops a Keychain Access prompt
|
|
306
|
+
* it is easy for the user to audit (``ls -la`` the JSON file)
|
|
307
|
+
* it stays on the user's machine and is never sent over the wire
|
|
308
|
+
|
|
309
|
+
The OS-native backends are tried as opt-in upgrades for users who
|
|
310
|
+
prefer OS-managed credentials.
|
|
311
|
+
"""
|
|
312
|
+
global _BACKEND, _BACKEND_NAME
|
|
313
|
+
if _BACKEND is not None:
|
|
314
|
+
return _BACKEND
|
|
315
|
+
forced = (os.environ.get("LOOP_MEMORY_SECRET_BACKEND") or "").strip().lower()
|
|
316
|
+
|
|
317
|
+
def _try(cls):
|
|
318
|
+
try:
|
|
319
|
+
b = cls()
|
|
320
|
+
return b
|
|
321
|
+
except Exception as e:
|
|
322
|
+
log.info("backend %s unavailable: %s", getattr(cls, "__name__", cls), e)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
if forced == "file" or not forced:
|
|
326
|
+
b = _try(FileSecretStore)
|
|
327
|
+
if b is not None:
|
|
328
|
+
_BACKEND, _BACKEND_NAME = b, b.name
|
|
329
|
+
log.info("secret backend: %s (local 0600 file)", b.name)
|
|
330
|
+
return _BACKEND
|
|
331
|
+
|
|
332
|
+
if forced in ("", "macos-keychain", "auto"):
|
|
333
|
+
if platform.system() == "Darwin":
|
|
334
|
+
b = _try(MacOSKeychainStore)
|
|
335
|
+
if b is not None:
|
|
336
|
+
_BACKEND, _BACKEND_NAME = b, b.name
|
|
337
|
+
return _BACKEND
|
|
338
|
+
|
|
339
|
+
if forced in ("", "linux-secret-service", "auto"):
|
|
340
|
+
if platform.system() == "Linux":
|
|
341
|
+
b = _try(LinuxSecretServiceStore)
|
|
342
|
+
if b is not None:
|
|
343
|
+
_BACKEND, _BACKEND_NAME = b, b.name
|
|
344
|
+
return _BACKEND
|
|
345
|
+
|
|
346
|
+
if forced in ("", "windows-cred", "auto"):
|
|
347
|
+
if platform.system() == "Windows":
|
|
348
|
+
b = _try(WindowsCredentialStore)
|
|
349
|
+
if b is not None:
|
|
350
|
+
_BACKEND, _BACKEND_NAME = b, b.name
|
|
351
|
+
return _BACKEND
|
|
352
|
+
|
|
353
|
+
# forced unknown / everything failed: fall back to file
|
|
354
|
+
b = _try(FileSecretStore)
|
|
355
|
+
_BACKEND, _BACKEND_NAME = b, b.name
|
|
356
|
+
return _BACKEND
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def backend_display_name() -> str:
|
|
360
|
+
"""Human-friendly name for the current backend (for UI tooltips)."""
|
|
361
|
+
name = backend_name()
|
|
362
|
+
return {
|
|
363
|
+
"local-file": "本地加密文件 (仅本机, 权限 0600)",
|
|
364
|
+
"encrypted-file-fallback": "本地加密文件 (仅本机, 权限 0600)",
|
|
365
|
+
"macos-keychain": "macOS Keychain (系统钥匙串)",
|
|
366
|
+
"linux-secret-service": "Linux Secret Service (libsecret)",
|
|
367
|
+
"windows-cred": "Windows Credential Manager",
|
|
368
|
+
}.get(name, name)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def backend_name() -> str:
|
|
372
|
+
_pick_backend()
|
|
373
|
+
return _BACKEND_NAME
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def account_for(provider: str, key: str = "api_key") -> str:
|
|
377
|
+
return _account_for(provider, key)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# Backends considered for *fallback* lookups (read-only). When the
|
|
381
|
+
# chosen backend doesn't have an account, we ask the others in order
|
|
382
|
+
# and on hit *migrate* the value into the chosen backend so subsequent
|
|
383
|
+
# reads are fast and the canonical store stays simple (one file, 0600).
|
|
384
|
+
_FALLBACK_BACKENDS: list = []
|
|
385
|
+
|
|
386
|
+
def _candidate_backends():
|
|
387
|
+
"""Return [active, *fallbacks] — active first, the rest in priority order.
|
|
388
|
+
|
|
389
|
+
The active backend is what ``_pick_backend()`` returned; it's also
|
|
390
|
+
where ``set_secret`` writes. Fallbacks are tried only on read miss
|
|
391
|
+
and migrated on hit, so the active store stays canonical.
|
|
392
|
+
"""
|
|
393
|
+
active = _pick_backend()
|
|
394
|
+
out = [active]
|
|
395
|
+
sysname = platform.system()
|
|
396
|
+
fallback_classes = []
|
|
397
|
+
if sysname == "Darwin":
|
|
398
|
+
fallback_classes.append(MacOSKeychainStore)
|
|
399
|
+
elif sysname == "Linux":
|
|
400
|
+
fallback_classes.append(LinuxSecretServiceStore)
|
|
401
|
+
elif sysname == "Windows":
|
|
402
|
+
fallback_classes.append(WindowsCredentialStore)
|
|
403
|
+
for cls in fallback_classes:
|
|
404
|
+
if cls is type(active):
|
|
405
|
+
continue
|
|
406
|
+
try:
|
|
407
|
+
b = cls()
|
|
408
|
+
except Exception:
|
|
409
|
+
continue
|
|
410
|
+
out.append(b)
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def get_secret(account: str) -> str | None:
|
|
415
|
+
"""Look up a secret. Falls back across backends and migrates on hit.
|
|
416
|
+
|
|
417
|
+
On read miss in the active backend, we try each OS-native backend
|
|
418
|
+
in turn. If any of them has the value, we copy it into the active
|
|
419
|
+
backend (so future reads are local-file fast) and return it. This
|
|
420
|
+
keeps the canonical store simple while never losing a key that was
|
|
421
|
+
saved when a different backend was active.
|
|
422
|
+
"""
|
|
423
|
+
active = _pick_backend()
|
|
424
|
+
try:
|
|
425
|
+
v = active.get(account)
|
|
426
|
+
if v:
|
|
427
|
+
return v
|
|
428
|
+
except Exception as e:
|
|
429
|
+
log.warning("secret get failed for %s in active: %s", account, e)
|
|
430
|
+
return None
|
|
431
|
+
# Active backend missed. Try the others and migrate on hit.
|
|
432
|
+
for b in _candidate_backends()[1:]:
|
|
433
|
+
try:
|
|
434
|
+
v = b.get(account)
|
|
435
|
+
except Exception as e:
|
|
436
|
+
log.debug("fallback get failed for %s in %s: %s", account, b.name, e)
|
|
437
|
+
continue
|
|
438
|
+
if v:
|
|
439
|
+
try:
|
|
440
|
+
active.set(account, v)
|
|
441
|
+
log.info("migrated secret %s from %s → %s", account, b.name, active.name)
|
|
442
|
+
except Exception as e:
|
|
443
|
+
log.warning("migration of %s to %s failed: %s", account, active.name, e)
|
|
444
|
+
return v
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def set_secret(account: str, value: str) -> None:
|
|
449
|
+
if not value:
|
|
450
|
+
delete_secret(account)
|
|
451
|
+
return
|
|
452
|
+
_pick_backend().set(account, value)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def delete_secret(account: str) -> bool:
|
|
456
|
+
try:
|
|
457
|
+
return _pick_backend().delete(account)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
log.warning("secret delete failed for %s: %s", account, e)
|
|
460
|
+
return False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def has_secret(account: str) -> bool:
|
|
464
|
+
return get_secret(account) is not None
|
|
File without changes
|