nexo-brain 7.30.11 → 7.30.12
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/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/cli_email.py +7 -22
- package/src/email_config.py +4 -17
- package/src/email_credentials.py +159 -0
- package/src/scripts/nexo-email-migrate-config.py +8 -15
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.12",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.30.
|
|
21
|
+
Version `7.30.12` is the current packaged-runtime line. Patch release over v7.30.11 - Desktop-managed email credentials now resolve through the shared credential helper, so Email NEXO migrations and CLI reads use keychain-backed markers before legacy SQLite fallback.
|
|
22
|
+
|
|
23
|
+
Previously in `7.30.11`: patch release over v7.30.10 - the installer and npm postinstall path now stamp the verified repair baseline too, so the first update from older packaged installs is covered without a manual second run.
|
|
22
24
|
|
|
23
25
|
Previously in `7.30.10`: patch release over v7.30.9 - packaged `nexo update` now stamps the verified repair baseline after import verification, including same-version maintenance runs.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
3
|
+
"version": "7.30.12",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
package/src/cli_email.py
CHANGED
|
@@ -21,7 +21,6 @@ from __future__ import annotations
|
|
|
21
21
|
import getpass
|
|
22
22
|
import json
|
|
23
23
|
import sys
|
|
24
|
-
import time
|
|
25
24
|
from typing import Any
|
|
26
25
|
|
|
27
26
|
|
|
@@ -74,30 +73,16 @@ def _sent_folder_from_account(account: dict | None) -> str:
|
|
|
74
73
|
|
|
75
74
|
|
|
76
75
|
def _store_credential(service: str, key: str, value: str) -> None:
|
|
77
|
-
"""Write password
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
conn = get_db()
|
|
82
|
-
now = time.time()
|
|
83
|
-
conn.execute(
|
|
84
|
-
"""
|
|
85
|
-
INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
|
|
86
|
-
VALUES (?, ?, ?, 'email account password (nexo email setup)', ?, ?)
|
|
87
|
-
ON CONFLICT(service, key) DO UPDATE SET
|
|
88
|
-
value = excluded.value,
|
|
89
|
-
updated_at = excluded.updated_at
|
|
90
|
-
""",
|
|
91
|
-
(service, key, value, now, now),
|
|
92
|
-
)
|
|
93
|
-
conn.commit()
|
|
76
|
+
"""Write password without echoing it back to stdout."""
|
|
77
|
+
from email_credentials import store_email_credential
|
|
78
|
+
|
|
79
|
+
store_email_credential(service, key, value, "email account password (nexo email setup)")
|
|
94
80
|
|
|
95
81
|
|
|
96
82
|
def _delete_credential(service: str, key: str) -> None:
|
|
97
|
-
from
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
conn.commit()
|
|
83
|
+
from email_credentials import delete_email_credential
|
|
84
|
+
|
|
85
|
+
delete_email_credential(service, key)
|
|
101
86
|
|
|
102
87
|
|
|
103
88
|
def cmd_email_setup(args) -> int:
|
package/src/email_config.py
CHANGED
|
@@ -41,24 +41,11 @@ LEGACY_CONFIG_PATH = _legacy_config_path()
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _get_credential(service: str, key: str) -> str:
|
|
44
|
-
"""Fetch a password from
|
|
45
|
-
on any miss so the caller can log-and-skip instead of crashing a cron.
|
|
46
|
-
"""
|
|
47
|
-
if not service or not key:
|
|
48
|
-
return ""
|
|
44
|
+
"""Fetch a password from keyring marker or legacy credentials table."""
|
|
49
45
|
try:
|
|
50
|
-
from
|
|
51
|
-
|
|
52
|
-
return
|
|
53
|
-
try:
|
|
54
|
-
conn = get_db()
|
|
55
|
-
row = conn.execute(
|
|
56
|
-
"SELECT value FROM credentials WHERE service = ? AND key = ?",
|
|
57
|
-
(service, key),
|
|
58
|
-
).fetchone()
|
|
59
|
-
if row is None:
|
|
60
|
-
return ""
|
|
61
|
-
return str(row[0] or "")
|
|
46
|
+
from email_credentials import read_email_credential
|
|
47
|
+
|
|
48
|
+
return read_email_credential(service, key)
|
|
62
49
|
except Exception as exc: # pragma: no cover
|
|
63
50
|
_logger.warning("credential lookup failed for %s/%s: %s", service, key, exc)
|
|
64
51
|
return ""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Credential storage for email account passwords.
|
|
2
|
+
|
|
3
|
+
Email account rows keep only ``credential_service`` + ``credential_key``.
|
|
4
|
+
Historically the referenced ``credentials.value`` column stored the password
|
|
5
|
+
directly. New writes try to place the secret in the OS keyring and keep a
|
|
6
|
+
``keyring://...`` marker in SQLite. Legacy plaintext values remain readable so
|
|
7
|
+
older installs keep working until they are rotated or migrated.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from urllib.parse import quote, unquote
|
|
16
|
+
|
|
17
|
+
KEYRING_SERVICE = "com.nexo.email"
|
|
18
|
+
KEYRING_MARKER_PREFIX = "keyring://"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _db():
|
|
22
|
+
from db._core import get_db
|
|
23
|
+
|
|
24
|
+
return get_db()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _marker(service: str, key: str) -> str:
|
|
28
|
+
return f"{KEYRING_MARKER_PREFIX}{quote(service, safe='')}/{quote(key, safe='')}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_marker(value: str) -> tuple[str, str] | None:
|
|
32
|
+
if not value.startswith(KEYRING_MARKER_PREFIX):
|
|
33
|
+
return None
|
|
34
|
+
rest = value[len(KEYRING_MARKER_PREFIX):]
|
|
35
|
+
if "/" not in rest:
|
|
36
|
+
return None
|
|
37
|
+
service, key = rest.split("/", 1)
|
|
38
|
+
return unquote(service), unquote(key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _account_name(service: str, key: str) -> str:
|
|
42
|
+
return f"{service}:{key}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _keyring_module():
|
|
46
|
+
try:
|
|
47
|
+
return importlib.import_module("keyring")
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_stored_value(service: str, key: str) -> str:
|
|
53
|
+
if not service or not key:
|
|
54
|
+
return ""
|
|
55
|
+
row = _db().execute(
|
|
56
|
+
"SELECT value FROM credentials WHERE service = ? AND key = ?",
|
|
57
|
+
(service, key),
|
|
58
|
+
).fetchone()
|
|
59
|
+
if row is None:
|
|
60
|
+
return ""
|
|
61
|
+
return str(row[0] or "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_db_value(service: str, key: str, value: str, notes: str) -> None:
|
|
65
|
+
conn = _db()
|
|
66
|
+
now = time.time()
|
|
67
|
+
conn.execute(
|
|
68
|
+
"""
|
|
69
|
+
INSERT INTO credentials (service, key, value, notes, created_at, updated_at)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
71
|
+
ON CONFLICT(service, key) DO UPDATE SET
|
|
72
|
+
value = excluded.value,
|
|
73
|
+
notes = excluded.notes,
|
|
74
|
+
updated_at = excluded.updated_at
|
|
75
|
+
""",
|
|
76
|
+
(service, key, value, notes, now, now),
|
|
77
|
+
)
|
|
78
|
+
conn.commit()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def store_email_credential(service: str, key: str, value: str, notes: str = "email account password") -> str:
|
|
82
|
+
"""Store an email password and return the SQLite value written.
|
|
83
|
+
|
|
84
|
+
Uses keyring when available. If the keyring backend is unavailable or
|
|
85
|
+
locked, the legacy SQLite plaintext path is used so existing automation
|
|
86
|
+
does not lose email access.
|
|
87
|
+
"""
|
|
88
|
+
service = str(service or "").strip()
|
|
89
|
+
key = str(key or "").strip()
|
|
90
|
+
value = str(value or "")
|
|
91
|
+
if not service or not key:
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
mode = os.environ.get("NEXO_EMAIL_CREDENTIAL_STORE", "auto").strip().lower()
|
|
95
|
+
keyring = None if mode in {"sqlite", "legacy", "plain", "plaintext"} else _keyring_module()
|
|
96
|
+
if keyring is not None:
|
|
97
|
+
try:
|
|
98
|
+
keyring.set_password(KEYRING_SERVICE, _account_name(service, key), value)
|
|
99
|
+
stored = _marker(service, key)
|
|
100
|
+
_write_db_value(service, key, stored, notes + " (stored in system keyring)")
|
|
101
|
+
return stored
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
if mode == "keyring":
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
_write_db_value(service, key, value, notes + " (legacy sqlite fallback)")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_email_credential(service: str, key: str) -> str:
|
|
113
|
+
"""Resolve an email password from keyring marker or legacy plaintext."""
|
|
114
|
+
stored = _read_stored_value(str(service or "").strip(), str(key or "").strip())
|
|
115
|
+
parsed = _parse_marker(stored)
|
|
116
|
+
if parsed is None:
|
|
117
|
+
return stored
|
|
118
|
+
|
|
119
|
+
keyring = _keyring_module()
|
|
120
|
+
if keyring is None:
|
|
121
|
+
return ""
|
|
122
|
+
try:
|
|
123
|
+
value = keyring.get_password(KEYRING_SERVICE, _account_name(*parsed))
|
|
124
|
+
except Exception:
|
|
125
|
+
return ""
|
|
126
|
+
return str(value or "")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def delete_email_credential(service: str, key: str) -> None:
|
|
130
|
+
service = str(service or "").strip()
|
|
131
|
+
key = str(key or "").strip()
|
|
132
|
+
if not service or not key:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
parsed = _parse_marker(_read_stored_value(service, key))
|
|
136
|
+
keyring = _keyring_module()
|
|
137
|
+
if parsed is not None and keyring is not None:
|
|
138
|
+
try:
|
|
139
|
+
keyring.delete_password(KEYRING_SERVICE, _account_name(*parsed))
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
conn = _db()
|
|
144
|
+
conn.execute("DELETE FROM credentials WHERE service = ? AND key = ?", (service, key))
|
|
145
|
+
conn.commit()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_keyring_marker(value: str) -> bool:
|
|
149
|
+
return _parse_marker(str(value or "")) is not None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = [
|
|
153
|
+
"KEYRING_MARKER_PREFIX",
|
|
154
|
+
"KEYRING_SERVICE",
|
|
155
|
+
"delete_email_credential",
|
|
156
|
+
"is_keyring_marker",
|
|
157
|
+
"read_email_credential",
|
|
158
|
+
"store_email_credential",
|
|
159
|
+
]
|
|
@@ -26,7 +26,6 @@ import json
|
|
|
26
26
|
import os
|
|
27
27
|
import re
|
|
28
28
|
import sys
|
|
29
|
-
import time
|
|
30
29
|
from pathlib import Path
|
|
31
30
|
|
|
32
31
|
|
|
@@ -121,20 +120,14 @@ def main(argv: list[str]) -> int:
|
|
|
121
120
|
print(f"[dry-run] credentials[{cred_service}/{cred_key}] = <password>")
|
|
122
121
|
return 0
|
|
123
122
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
VALUES (?, ?, ?, 'migrated from ~/.nexo/nexo-email/config.json (F1)', ?, ?)
|
|
132
|
-
ON CONFLICT(service, key) DO UPDATE SET
|
|
133
|
-
value = excluded.value, updated_at = excluded.updated_at
|
|
134
|
-
""",
|
|
135
|
-
(cred_service, cred_key, password, now, now),
|
|
123
|
+
from email_credentials import store_email_credential
|
|
124
|
+
|
|
125
|
+
store_email_credential(
|
|
126
|
+
cred_service,
|
|
127
|
+
cred_key,
|
|
128
|
+
password,
|
|
129
|
+
"migrated from ~/.nexo/nexo-email/config.json (F1)",
|
|
136
130
|
)
|
|
137
|
-
conn.commit()
|
|
138
131
|
|
|
139
132
|
if existing and not args.force:
|
|
140
133
|
existing_metadata = existing.get("metadata") if isinstance(existing.get("metadata"), dict) else {}
|
|
@@ -194,7 +187,7 @@ def main(argv: list[str]) -> int:
|
|
|
194
187
|
can_send=True,
|
|
195
188
|
)
|
|
196
189
|
print(f"✓ Cuenta agente '{args.label}' migrada ({account.get('email')}).")
|
|
197
|
-
print(f" Password guardada en
|
|
190
|
+
print(f" Password guardada en el almacen de credenciales[{cred_service}/{cred_key}].")
|
|
198
191
|
print(
|
|
199
192
|
" Metadata: "
|
|
200
193
|
f"operator_aliases={len(legacy_operator_aliases)}, trusted_domains={len(trusted)}."
|