nexo-brain 7.20.11 → 7.20.13
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 +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +36 -7
- package/src/cli.py +8 -0
- package/src/crons/sync.py +68 -0
- package/src/db_guard.py +272 -0
- package/src/doctor/providers/boot.py +148 -0
- package/src/local_context/__init__.py +2 -0
- package/src/local_context/api.py +574 -56
- package/src/plugins/recover.py +22 -9
- package/src/server.py +90 -2
- package/src/tools_api_call.py +196 -0
- package/src/tools_credentials.py +180 -8
- package/src/tools_hot_context.py +4 -32
- package/tool-enforcement-map.json +392 -316
package/src/plugins/recover.py
CHANGED
|
@@ -41,7 +41,8 @@ from db_guard import (
|
|
|
41
41
|
db_row_counts,
|
|
42
42
|
diff_row_counts,
|
|
43
43
|
find_latest_hourly_backup,
|
|
44
|
-
|
|
44
|
+
quiesce_nexo_db_writers,
|
|
45
|
+
resume_nexo_launchagents,
|
|
45
46
|
safe_sqlite_backup,
|
|
46
47
|
validate_backup_matches_source,
|
|
47
48
|
)
|
|
@@ -257,15 +258,20 @@ def recover(
|
|
|
257
258
|
result["steps"].append("dry-run: stopping before any write")
|
|
258
259
|
return result
|
|
259
260
|
|
|
260
|
-
|
|
261
|
+
stopped_launchagents: list[str] = []
|
|
262
|
+
|
|
263
|
+
# Step 3: quiesce live DB writers
|
|
261
264
|
if not skip_kill:
|
|
262
|
-
|
|
263
|
-
result["
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
quiesce_report = quiesce_nexo_db_writers(dry_run=False)
|
|
266
|
+
result["quiesce"] = quiesce_report
|
|
267
|
+
stopped_launchagents = list((quiesce_report.get("launchagents") or {}).get("stopped") or [])
|
|
268
|
+
result["steps"].append(
|
|
269
|
+
"quiesce_db_writers: "
|
|
270
|
+
f"terminated={quiesce_report.get('terminated', 0)} "
|
|
271
|
+
f"launchagents={len(stopped_launchagents)}"
|
|
272
|
+
)
|
|
273
|
+
if quiesce_report.get("errors"):
|
|
274
|
+
result["warnings"].extend(quiesce_report["errors"])
|
|
269
275
|
|
|
270
276
|
# Step 4: snapshot current state to pre-recover/
|
|
271
277
|
pre_recover_dir = _backup_base() / f"pre-recover-{time.strftime('%Y-%m-%d-%H%M%S')}"
|
|
@@ -297,17 +303,24 @@ def recover(
|
|
|
297
303
|
ok, copy_err = safe_sqlite_backup(chosen, target_path)
|
|
298
304
|
if not ok:
|
|
299
305
|
result["errors"].append(f"restore copy failed: {copy_err}")
|
|
306
|
+
if stopped_launchagents:
|
|
307
|
+
result["resume"] = resume_nexo_launchagents(stopped_launchagents)
|
|
300
308
|
return result
|
|
301
309
|
result["steps"].append(f"restored {chosen.name} -> {target_path}")
|
|
302
310
|
|
|
303
311
|
valid, valid_err = validate_backup_matches_source(chosen, target_path)
|
|
304
312
|
if not valid:
|
|
305
313
|
result["errors"].append(f"post-restore validation failed: {valid_err}")
|
|
314
|
+
if stopped_launchagents:
|
|
315
|
+
result["resume"] = resume_nexo_launchagents(stopped_launchagents)
|
|
306
316
|
return result
|
|
307
317
|
result["steps"].append("validated post-restore row counts")
|
|
308
318
|
|
|
309
319
|
final_counts = db_row_counts(target_path)
|
|
310
320
|
result["final_row_counts"] = {k: v for k, v in final_counts.items() if v is not None}
|
|
321
|
+
if stopped_launchagents:
|
|
322
|
+
result["resume"] = resume_nexo_launchagents(stopped_launchagents)
|
|
323
|
+
result["steps"].append(f"resumed {len((result['resume'] or {}).get('started') or [])} launchagent(s)")
|
|
311
324
|
result["ok"] = True
|
|
312
325
|
return result
|
|
313
326
|
|
package/src/server.py
CHANGED
|
@@ -104,6 +104,7 @@ from plugins.workflow import (
|
|
|
104
104
|
)
|
|
105
105
|
from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
|
|
106
106
|
from tools_guardian import handle_guardian_rule_override
|
|
107
|
+
from tools_api_call import handle_api_call, handle_create_app_token
|
|
107
108
|
from runtime_versioning import (
|
|
108
109
|
RestartRequiredMiddleware,
|
|
109
110
|
build_mcp_status,
|
|
@@ -682,14 +683,45 @@ def nexo_local_index_exclusions(action: str = "list", path: str = "", reason: st
|
|
|
682
683
|
|
|
683
684
|
|
|
684
685
|
@mcp.tool
|
|
685
|
-
def nexo_local_context(
|
|
686
|
-
|
|
686
|
+
def nexo_local_context(
|
|
687
|
+
query: str,
|
|
688
|
+
intent: str = "answer",
|
|
689
|
+
limit: int = 8,
|
|
690
|
+
evidence_required: bool = True,
|
|
691
|
+
current_context: str = "",
|
|
692
|
+
mode: str = "compact",
|
|
693
|
+
max_chars: int = 20000,
|
|
694
|
+
include_entities: bool = False,
|
|
695
|
+
include_relations: bool = False,
|
|
696
|
+
) -> str:
|
|
697
|
+
"""Retrieve local evidence before answering or acting.
|
|
698
|
+
|
|
699
|
+
Use mode='compact' for normal answers. Use mode='full' only for deep
|
|
700
|
+
debugging, ideally with a higher max_chars and a specific query.
|
|
701
|
+
"""
|
|
687
702
|
result = local_context_api.context_query(
|
|
688
703
|
query,
|
|
689
704
|
intent=intent,
|
|
690
705
|
limit=limit,
|
|
691
706
|
evidence_required=evidence_required,
|
|
692
707
|
current_context=current_context,
|
|
708
|
+
mode=mode,
|
|
709
|
+
max_chars=max_chars,
|
|
710
|
+
include_entities=include_entities,
|
|
711
|
+
include_relations=include_relations,
|
|
712
|
+
)
|
|
713
|
+
return json.dumps(result, ensure_ascii=False)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@mcp.tool
|
|
717
|
+
def nexo_context_router(query: str, intent: str = "answer", limit: int = 4, current_context: str = "", max_chars: int = 6000) -> str:
|
|
718
|
+
"""Return compact local context evidence suitable for injection before a reply."""
|
|
719
|
+
result = local_context_api.context_router(
|
|
720
|
+
query,
|
|
721
|
+
intent=intent,
|
|
722
|
+
limit=limit,
|
|
723
|
+
current_context=current_context,
|
|
724
|
+
max_chars=max_chars,
|
|
693
725
|
)
|
|
694
726
|
return json.dumps(result, ensure_ascii=False)
|
|
695
727
|
|
|
@@ -2058,6 +2090,62 @@ def nexo_session_log_close(
|
|
|
2058
2090
|
return _json.dumps(result, ensure_ascii=False)
|
|
2059
2091
|
|
|
2060
2092
|
|
|
2093
|
+
@mcp.tool
|
|
2094
|
+
def nexo_api_call(
|
|
2095
|
+
method: str,
|
|
2096
|
+
path: str,
|
|
2097
|
+
body_json: str = "",
|
|
2098
|
+
idempotency_key: str = "",
|
|
2099
|
+
headers_json: str = "",
|
|
2100
|
+
base_url: str = "",
|
|
2101
|
+
) -> str:
|
|
2102
|
+
"""Make an authenticated HTTP request to the NEXO Desktop backend (nexo-desktop.com).
|
|
2103
|
+
|
|
2104
|
+
The session bearer is auto-loaded from the OS keychain — the agent never
|
|
2105
|
+
sees or handles tokens. Use this for any /api/* endpoint the user has
|
|
2106
|
+
permission for: provider-proxy/*, credits/*, cards/*, auth/app-tokens, etc.
|
|
2107
|
+
|
|
2108
|
+
Args:
|
|
2109
|
+
method: HTTP method (GET / POST / PUT / DELETE / PATCH).
|
|
2110
|
+
path: path starting with '/' (e.g. '/api/provider-proxy/call').
|
|
2111
|
+
body_json: JSON string of the request body. Empty for GET.
|
|
2112
|
+
idempotency_key: UUID v4 to dedupe POST/PUT retries (avoids double-charge).
|
|
2113
|
+
headers_json: optional extra headers as a JSON object. Authorization is ignored.
|
|
2114
|
+
base_url: override default base (default: https://nexo-desktop.com).
|
|
2115
|
+
|
|
2116
|
+
Returns formatted text with HTTP status + parsed JSON body. Bearer is never echoed.
|
|
2117
|
+
"""
|
|
2118
|
+
return handle_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
@mcp.tool
|
|
2122
|
+
def nexo_create_app_token(
|
|
2123
|
+
name: str,
|
|
2124
|
+
abilities: str = "",
|
|
2125
|
+
allowed_platforms: str = "",
|
|
2126
|
+
expires_at: str = "",
|
|
2127
|
+
) -> str:
|
|
2128
|
+
"""Create a persistent AppToken for the current user via POST /api/auth/app-tokens.
|
|
2129
|
+
|
|
2130
|
+
Use this when a card needs to mint a token that will live inside a snippet
|
|
2131
|
+
the user pastes on their own website (chatbot widget, embed, public API
|
|
2132
|
+
autoresponder). The plain-text token is returned ONCE — embed it in the
|
|
2133
|
+
generated snippet and never store it elsewhere.
|
|
2134
|
+
|
|
2135
|
+
Args:
|
|
2136
|
+
name: human label for the token (e.g. 'chatbot-mitienda-com').
|
|
2137
|
+
abilities: comma-separated abilities. Allowed:
|
|
2138
|
+
provider-proxy:call, provider-proxy:estimate, credits:read.
|
|
2139
|
+
Defaults to 'provider-proxy:call' if empty.
|
|
2140
|
+
allowed_platforms: comma-separated platform keys (openai, anthropic, gemini, ...).
|
|
2141
|
+
Empty = all platforms the user has access to.
|
|
2142
|
+
expires_at: ISO 8601 future date, empty for non-expiring token.
|
|
2143
|
+
|
|
2144
|
+
Returns the created token summary + plain_text_token (one-time disclosure).
|
|
2145
|
+
"""
|
|
2146
|
+
return handle_create_app_token(name, abilities, allowed_platforms, expires_at)
|
|
2147
|
+
|
|
2148
|
+
|
|
2061
2149
|
if __name__ == "__main__":
|
|
2062
2150
|
_server_init()
|
|
2063
2151
|
mcp.run(**_run_kwargs_from_env())
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""HTTP API calls to the nexo-desktop-web backend using the user's session bearer.
|
|
2
|
+
|
|
3
|
+
Exposes two MCP tools registered in server.py:
|
|
4
|
+
- nexo_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
|
|
5
|
+
- nexo_create_app_token(name, abilities, allowed_platforms, expires_at)
|
|
6
|
+
|
|
7
|
+
The session bearer (Sanctum personal access token) is stored by NEXO Desktop in
|
|
8
|
+
the OS keychain at:
|
|
9
|
+
service = "com.nexo.shared-auth"
|
|
10
|
+
account = "sanctum-token"
|
|
11
|
+
|
|
12
|
+
Cross-platform via the `keyring` library (macOS Keychain, Windows Credential
|
|
13
|
+
Manager, secret-service on Linux). The bearer is never echoed back to the
|
|
14
|
+
agent — it only flows in the Authorization header to the backend.
|
|
15
|
+
|
|
16
|
+
These tools let the agent (Nero) call any /api/* endpoint on the user's behalf
|
|
17
|
+
without the agent having to manage tokens. Use them from fichas that need
|
|
18
|
+
backend-driven NEXO Credits flows, or to mint persistent AppTokens for
|
|
19
|
+
embeddable resources (chatbot snippets, widgets) that the user pastes on
|
|
20
|
+
their own website.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import keyring
|
|
29
|
+
import requests
|
|
30
|
+
|
|
31
|
+
KEYCHAIN_SERVICE = "com.nexo.shared-auth"
|
|
32
|
+
KEYCHAIN_ACCOUNT = "sanctum-token"
|
|
33
|
+
DEFAULT_BASE_URL = "https://nexo-desktop.com"
|
|
34
|
+
ALLOWED_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"}
|
|
35
|
+
REQUEST_TIMEOUT_SECONDS = 60
|
|
36
|
+
MAX_BODY_PREVIEW_CHARS = 6000
|
|
37
|
+
|
|
38
|
+
# Allowed abilities for AppToken creation. Keep in sync with
|
|
39
|
+
# AppTokenService::ABILITY_* constants in the Laravel backend.
|
|
40
|
+
ALLOWED_ABILITIES = {
|
|
41
|
+
"provider-proxy:call",
|
|
42
|
+
"provider-proxy:estimate",
|
|
43
|
+
"credits:read",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_session_bearer() -> str | None:
|
|
48
|
+
try:
|
|
49
|
+
return keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_json_arg(raw: str, label: str) -> tuple[Any, str | None]:
|
|
55
|
+
text = (raw or "").strip()
|
|
56
|
+
if not text:
|
|
57
|
+
return None, None
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(text), None
|
|
60
|
+
except json.JSONDecodeError as exc:
|
|
61
|
+
return None, f"ERROR: {label} is not valid JSON: {exc}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_response(method: str, path: str, status: int, body_text: str) -> str:
|
|
65
|
+
return f"HTTP {status} {method} {path}\n{body_text}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def handle_api_call(
|
|
69
|
+
method: str,
|
|
70
|
+
path: str,
|
|
71
|
+
body_json: str = "",
|
|
72
|
+
idempotency_key: str = "",
|
|
73
|
+
headers_json: str = "",
|
|
74
|
+
base_url: str = "",
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Make an authenticated request to the NEXO Desktop backend.
|
|
77
|
+
|
|
78
|
+
The session bearer is auto-loaded from the OS keychain. It is never
|
|
79
|
+
returned to the caller. Use this for any /api/* endpoint the user has
|
|
80
|
+
permission for (provider-proxy/*, credits/*, cards/*, etc).
|
|
81
|
+
"""
|
|
82
|
+
bearer = _read_session_bearer()
|
|
83
|
+
if not bearer:
|
|
84
|
+
return (
|
|
85
|
+
"ERROR: no NEXO Desktop session token found in the system keychain. "
|
|
86
|
+
"The user must be logged in to NEXO Desktop before this tool can be used."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
method_upper = (method or "").strip().upper()
|
|
90
|
+
if method_upper not in ALLOWED_METHODS:
|
|
91
|
+
return f"ERROR: method '{method}' is not allowed. Use one of {sorted(ALLOWED_METHODS)}."
|
|
92
|
+
|
|
93
|
+
cleaned_path = (path or "").strip()
|
|
94
|
+
if not cleaned_path.startswith("/"):
|
|
95
|
+
return "ERROR: path must start with '/' (e.g. /api/provider-proxy/call)."
|
|
96
|
+
|
|
97
|
+
base = (base_url or "").strip() or DEFAULT_BASE_URL
|
|
98
|
+
url = base.rstrip("/") + cleaned_path
|
|
99
|
+
|
|
100
|
+
headers = {
|
|
101
|
+
"Authorization": f"Bearer {bearer}",
|
|
102
|
+
"Accept": "application/json",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
extra_headers, err = _parse_json_arg(headers_json, "headers_json")
|
|
106
|
+
if err:
|
|
107
|
+
return err
|
|
108
|
+
if extra_headers is not None:
|
|
109
|
+
if not isinstance(extra_headers, dict):
|
|
110
|
+
return "ERROR: headers_json must be a JSON object."
|
|
111
|
+
for k, v in extra_headers.items():
|
|
112
|
+
# Never let caller override Authorization.
|
|
113
|
+
if str(k).lower() == "authorization":
|
|
114
|
+
continue
|
|
115
|
+
headers[str(k)] = str(v)
|
|
116
|
+
|
|
117
|
+
if idempotency_key.strip():
|
|
118
|
+
headers["Idempotency-Key"] = idempotency_key.strip()
|
|
119
|
+
|
|
120
|
+
body, err = _parse_json_arg(body_json, "body_json")
|
|
121
|
+
if err:
|
|
122
|
+
return err
|
|
123
|
+
if body is not None:
|
|
124
|
+
headers["Content-Type"] = "application/json"
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
resp = requests.request(
|
|
128
|
+
method_upper,
|
|
129
|
+
url,
|
|
130
|
+
headers=headers,
|
|
131
|
+
json=body,
|
|
132
|
+
timeout=REQUEST_TIMEOUT_SECONDS,
|
|
133
|
+
)
|
|
134
|
+
except requests.exceptions.Timeout:
|
|
135
|
+
return f"ERROR: request to {cleaned_path} timed out after {REQUEST_TIMEOUT_SECONDS}s."
|
|
136
|
+
except requests.exceptions.RequestException as exc:
|
|
137
|
+
return f"ERROR: network error calling {cleaned_path}: {exc}"
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
parsed = resp.json()
|
|
141
|
+
body_text = json.dumps(parsed, indent=2, ensure_ascii=False)
|
|
142
|
+
except ValueError:
|
|
143
|
+
body_text = (resp.text or "")[:MAX_BODY_PREVIEW_CHARS]
|
|
144
|
+
|
|
145
|
+
if len(body_text) > MAX_BODY_PREVIEW_CHARS:
|
|
146
|
+
body_text = body_text[:MAX_BODY_PREVIEW_CHARS] + "\n... [truncated]"
|
|
147
|
+
|
|
148
|
+
return _format_response(method_upper, cleaned_path, resp.status_code, body_text)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def handle_create_app_token(
|
|
152
|
+
name: str,
|
|
153
|
+
abilities: str = "",
|
|
154
|
+
allowed_platforms: str = "",
|
|
155
|
+
expires_at: str = "",
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Create a persistent AppToken for the current user via POST /api/auth/app-tokens.
|
|
158
|
+
|
|
159
|
+
Use this when a card needs to mint a token that will live inside a snippet
|
|
160
|
+
the user pastes on their own website (chatbot widget, embed, public API
|
|
161
|
+
autoresponder). The plain-text token is returned ONCE — the agent must
|
|
162
|
+
embed it in the snippet immediately and never store it elsewhere.
|
|
163
|
+
"""
|
|
164
|
+
label = (name or "").strip()
|
|
165
|
+
if not label:
|
|
166
|
+
return "ERROR: name is required (human label for the token, e.g. 'chatbot-mitienda-com')."
|
|
167
|
+
|
|
168
|
+
requested_abilities = [a.strip() for a in (abilities or "").split(",") if a.strip()]
|
|
169
|
+
if not requested_abilities:
|
|
170
|
+
requested_abilities = ["provider-proxy:call"]
|
|
171
|
+
|
|
172
|
+
invalid = [a for a in requested_abilities if a not in ALLOWED_ABILITIES]
|
|
173
|
+
if invalid:
|
|
174
|
+
return (
|
|
175
|
+
f"ERROR: invalid abilities {invalid}. "
|
|
176
|
+
f"Allowed: {sorted(ALLOWED_ABILITIES)}."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
payload: dict[str, Any] = {
|
|
180
|
+
"name": label,
|
|
181
|
+
"abilities": requested_abilities,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
platforms = [p.strip() for p in (allowed_platforms or "").split(",") if p.strip()]
|
|
185
|
+
if platforms:
|
|
186
|
+
payload["allowed_platforms"] = platforms
|
|
187
|
+
|
|
188
|
+
expires_clean = (expires_at or "").strip()
|
|
189
|
+
if expires_clean:
|
|
190
|
+
payload["expires_at"] = expires_clean
|
|
191
|
+
|
|
192
|
+
return handle_api_call(
|
|
193
|
+
method="POST",
|
|
194
|
+
path="/api/auth/app-tokens",
|
|
195
|
+
body_json=json.dumps(payload),
|
|
196
|
+
)
|
package/src/tools_credentials.py
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
|
-
"""Credentials CRUD tools: get, create, update, delete, list.
|
|
1
|
+
"""Credentials CRUD tools: get, create, update, delete, list.
|
|
2
|
+
|
|
3
|
+
Two storage backends live behind these handlers:
|
|
4
|
+
|
|
5
|
+
1) The default SQLite credentials table (DB). Used by the agent for any
|
|
6
|
+
internally-managed secret (Anthropic platform key, Stripe keys, etc.).
|
|
7
|
+
|
|
8
|
+
2) The BYOK filesystem store at ``~/.nexo/credentials/byok/{slug}.json``.
|
|
9
|
+
Used for the user's own API keys connected from NEXO Desktop's
|
|
10
|
+
"Connections" settings tab. Keys NEVER cross to the NEXO backend and
|
|
11
|
+
never go through the DB — they live only on the user's machine.
|
|
12
|
+
|
|
13
|
+
When ``service='byok'`` the handlers transparently route to the filesystem
|
|
14
|
+
backend (get / list / delete). Create is intentionally NOT routed: BYOK
|
|
15
|
+
keys are written through Desktop's UI, which performs remote validation
|
|
16
|
+
before persisting. The agent should never mint a BYOK entry on its own.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
2
22
|
|
|
3
23
|
from db import create_credential, update_credential, delete_credential, get_credential, list_credentials, get_db
|
|
4
24
|
|
|
5
25
|
|
|
26
|
+
BYOK_SERVICE = "byok"
|
|
27
|
+
|
|
28
|
+
|
|
6
29
|
def _credential_exists(service: str, key: str) -> bool:
|
|
7
30
|
"""Fase 2 R02 helper — exact (service, key) match against active credentials.
|
|
8
31
|
|
|
@@ -20,9 +43,116 @@ def _credential_exists(service: str, key: str) -> bool:
|
|
|
20
43
|
return row is not None
|
|
21
44
|
|
|
22
45
|
|
|
46
|
+
def _byok_base_dir() -> Path:
|
|
47
|
+
home = os.environ.get("NEXO_HOME") or str(Path.home() / ".nexo")
|
|
48
|
+
return Path(home) / "credentials" / "byok"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe_byok_slug(raw: str) -> str:
|
|
52
|
+
cleaned = (raw or "").strip().lower()
|
|
53
|
+
return "".join(ch for ch in cleaned if ch.isalnum() or ch in "-_")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _byok_file_for(key: str) -> Path | None:
|
|
57
|
+
safe = _safe_byok_slug(key)
|
|
58
|
+
if not safe:
|
|
59
|
+
return None
|
|
60
|
+
return _byok_base_dir() / f"{safe}.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _byok_read_file(path: Path) -> dict | None:
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _byok_entry_from_file(slug: str, data: dict) -> dict:
|
|
71
|
+
"""Shape a BYOK file into the {service, key, value, notes} schema the
|
|
72
|
+
handlers expect — same as DB rows. ``value`` carries the actual API key.
|
|
73
|
+
"""
|
|
74
|
+
provider = data.get("provider") or slug
|
|
75
|
+
label = data.get("label") or ""
|
|
76
|
+
validation = data.get("validation_status") or "unknown"
|
|
77
|
+
connected_at = data.get("connected_at") or ""
|
|
78
|
+
last_validated = data.get("last_validated_at") or ""
|
|
79
|
+
note_parts = [
|
|
80
|
+
f"provider={provider}",
|
|
81
|
+
f"label={label}" if label else "",
|
|
82
|
+
f"validation={validation}",
|
|
83
|
+
f"connected_at={connected_at}" if connected_at else "",
|
|
84
|
+
f"last_validated_at={last_validated}" if last_validated else "",
|
|
85
|
+
]
|
|
86
|
+
notes = " | ".join(p for p in note_parts if p)
|
|
87
|
+
return {
|
|
88
|
+
"service": BYOK_SERVICE,
|
|
89
|
+
"key": slug,
|
|
90
|
+
"value": str(data.get("api_key") or ""),
|
|
91
|
+
"notes": notes,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _byok_get(key: str = "") -> list[dict]:
|
|
96
|
+
"""Filesystem-backed lookup for BYOK keys. Returns list of dicts in the
|
|
97
|
+
same shape as get_credential() so the handler can render them uniformly.
|
|
98
|
+
"""
|
|
99
|
+
base = _byok_base_dir()
|
|
100
|
+
if not base.is_dir():
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
if key:
|
|
104
|
+
path = _byok_file_for(key)
|
|
105
|
+
if not path or not path.is_file():
|
|
106
|
+
return []
|
|
107
|
+
data = _byok_read_file(path)
|
|
108
|
+
if data is None:
|
|
109
|
+
return []
|
|
110
|
+
return [_byok_entry_from_file(_safe_byok_slug(key), data)]
|
|
111
|
+
|
|
112
|
+
out: list[dict] = []
|
|
113
|
+
for path in sorted(base.glob("*.json")):
|
|
114
|
+
data = _byok_read_file(path)
|
|
115
|
+
if data is None:
|
|
116
|
+
continue
|
|
117
|
+
out.append(_byok_entry_from_file(path.stem, data))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _byok_delete(key: str = "") -> int:
|
|
122
|
+
"""Filesystem-backed delete. Returns the number of files removed."""
|
|
123
|
+
base = _byok_base_dir()
|
|
124
|
+
if not base.is_dir():
|
|
125
|
+
return 0
|
|
126
|
+
if key:
|
|
127
|
+
path = _byok_file_for(key)
|
|
128
|
+
if not path or not path.is_file():
|
|
129
|
+
return 0
|
|
130
|
+
try:
|
|
131
|
+
path.unlink()
|
|
132
|
+
return 1
|
|
133
|
+
except Exception:
|
|
134
|
+
return 0
|
|
135
|
+
removed = 0
|
|
136
|
+
for path in base.glob("*.json"):
|
|
137
|
+
try:
|
|
138
|
+
path.unlink()
|
|
139
|
+
removed += 1
|
|
140
|
+
except Exception:
|
|
141
|
+
continue
|
|
142
|
+
return removed
|
|
143
|
+
|
|
144
|
+
|
|
23
145
|
def handle_credential_get(service: str, key: str = '') -> str:
|
|
24
|
-
"""Retrieve credential(s) including their values. Use for reading secrets.
|
|
25
|
-
|
|
146
|
+
"""Retrieve credential(s) including their values. Use for reading secrets.
|
|
147
|
+
|
|
148
|
+
When ``service='byok'`` the values are read from the local filesystem
|
|
149
|
+
store written by NEXO Desktop's Settings > Connections UI (the user's
|
|
150
|
+
own provider API keys, e.g. OpenAI/Anthropic/Gemini/ElevenLabs).
|
|
151
|
+
"""
|
|
152
|
+
if service == BYOK_SERVICE:
|
|
153
|
+
results = _byok_get(key)
|
|
154
|
+
else:
|
|
155
|
+
results = get_credential(service, key if key else None)
|
|
26
156
|
if not results:
|
|
27
157
|
target = f"{service}/{key}" if key else service
|
|
28
158
|
return f"ERROR: No credentials found for '{target}'."
|
|
@@ -50,7 +180,19 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
|
|
|
50
180
|
force: Set to '1'/'true' to OVERWRITE an existing (service, key) pair.
|
|
51
181
|
Without force, Fase 2 R02 rejects duplicates and points at
|
|
52
182
|
nexo_credential_update as the canonical edit path.
|
|
183
|
+
|
|
184
|
+
BYOK keys (service='byok') are intentionally NOT mintable through this
|
|
185
|
+
tool: they must be added via NEXO Desktop's Settings > Connections, which
|
|
186
|
+
validates the key against the provider before saving. The agent should
|
|
187
|
+
not silently inject BYOK credentials.
|
|
53
188
|
"""
|
|
189
|
+
if service == BYOK_SERVICE:
|
|
190
|
+
return (
|
|
191
|
+
"ERROR: BYOK credentials cannot be created through this tool. "
|
|
192
|
+
"Ask the user to open NEXO Desktop > Settings > Connections and "
|
|
193
|
+
"connect the provider there (the UI validates the key with the "
|
|
194
|
+
"provider before saving)."
|
|
195
|
+
)
|
|
54
196
|
# ── R02 (Fase 2 Protocol Enforcer): reject exact (service, key) duplicates ──
|
|
55
197
|
force_flag = str(force or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
56
198
|
if not force_flag and _credential_exists(service, key):
|
|
@@ -71,7 +213,16 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
|
|
|
71
213
|
|
|
72
214
|
|
|
73
215
|
def handle_credential_update(service: str, key: str, value: str = '', notes: str = '') -> str:
|
|
74
|
-
"""Update the value and/or notes of an existing credential.
|
|
216
|
+
"""Update the value and/or notes of an existing credential.
|
|
217
|
+
|
|
218
|
+
BYOK entries are not editable from the agent side; users update them by
|
|
219
|
+
re-connecting the provider from Settings > Connections in Desktop.
|
|
220
|
+
"""
|
|
221
|
+
if service == BYOK_SERVICE:
|
|
222
|
+
return (
|
|
223
|
+
"ERROR: BYOK credentials are not editable from the agent. "
|
|
224
|
+
"Ask the user to update the connection in NEXO Desktop > Settings > Connections."
|
|
225
|
+
)
|
|
75
226
|
result = update_credential(
|
|
76
227
|
service,
|
|
77
228
|
key,
|
|
@@ -84,7 +235,20 @@ def handle_credential_update(service: str, key: str, value: str = '', notes: str
|
|
|
84
235
|
|
|
85
236
|
|
|
86
237
|
def handle_credential_delete(service: str, key: str = '') -> str:
|
|
87
|
-
"""Delete a credential or all credentials for a service.
|
|
238
|
+
"""Delete a credential or all credentials for a service.
|
|
239
|
+
|
|
240
|
+
For ``service='byok'`` the delete reaches the filesystem store; the file
|
|
241
|
+
is removed but the provider account on the user's side is untouched.
|
|
242
|
+
"""
|
|
243
|
+
if service == BYOK_SERVICE:
|
|
244
|
+
removed = _byok_delete(key if key else None)
|
|
245
|
+
if removed == 0:
|
|
246
|
+
target = f"{service}/{key}" if key else service
|
|
247
|
+
return f"ERROR: No credentials found for '{target}'."
|
|
248
|
+
if key:
|
|
249
|
+
return f"Credential deleted."
|
|
250
|
+
return f"All BYOK credentials deleted ({removed} files)."
|
|
251
|
+
|
|
88
252
|
deleted = delete_credential(service, key if key else None)
|
|
89
253
|
if not deleted:
|
|
90
254
|
target = f"{service}/{key}" if key else service
|
|
@@ -95,9 +259,17 @@ def handle_credential_delete(service: str, key: str = '') -> str:
|
|
|
95
259
|
|
|
96
260
|
|
|
97
261
|
def handle_credential_list(service: str = '') -> str:
|
|
98
|
-
"""List credential service/key names and notes — values are never shown.
|
|
99
|
-
|
|
100
|
-
|
|
262
|
+
"""List credential service/key names and notes — values are never shown.
|
|
263
|
+
|
|
264
|
+
Listing without ``service`` only returns DB entries (the historical
|
|
265
|
+
behaviour). Pass ``service='byok'`` to list the BYOK filesystem store.
|
|
266
|
+
"""
|
|
267
|
+
if service == BYOK_SERVICE:
|
|
268
|
+
results = _byok_get("")
|
|
269
|
+
label = "BYOK"
|
|
270
|
+
else:
|
|
271
|
+
results = list_credentials(service if service else None)
|
|
272
|
+
label = service if service else "ALL"
|
|
101
273
|
if not results:
|
|
102
274
|
return f"CREDENTIALS {label.upper()}: No entries."
|
|
103
275
|
lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
|
package/src/tools_hot_context.py
CHANGED
|
@@ -27,45 +27,17 @@ def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
|
|
|
27
27
|
try:
|
|
28
28
|
from local_context import api as local_context_api
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
routed = local_context_api.context_router(
|
|
31
31
|
clean_query,
|
|
32
32
|
intent="pre_action",
|
|
33
33
|
limit=max(1, min(int(limit or 4), 8)),
|
|
34
|
-
|
|
34
|
+
max_chars=6000,
|
|
35
35
|
)
|
|
36
36
|
except Exception:
|
|
37
37
|
return ""
|
|
38
|
-
|
|
39
|
-
if not assets:
|
|
38
|
+
if not routed.get("should_inject"):
|
|
40
39
|
return ""
|
|
41
|
-
|
|
42
|
-
for asset in assets[:limit]:
|
|
43
|
-
display_path = str(asset.get("display_path") or asset.get("path") or "")
|
|
44
|
-
score = asset.get("score")
|
|
45
|
-
summary = str(asset.get("summary") or "").strip()
|
|
46
|
-
suffix = f" — {summary[:180]}" if summary else ""
|
|
47
|
-
lines.append(f"- {display_path} ({asset.get('file_type', 'file')}, score={score}){suffix}")
|
|
48
|
-
chunks = result.get("chunks") or []
|
|
49
|
-
if chunks:
|
|
50
|
-
lines.append("Relevant excerpts:")
|
|
51
|
-
for chunk in chunks[:limit]:
|
|
52
|
-
text = " ".join(str(chunk.get("text") or "").split())
|
|
53
|
-
if not text:
|
|
54
|
-
continue
|
|
55
|
-
lines.append(f"- {text[:360]}")
|
|
56
|
-
refs = result.get("evidence_refs") or []
|
|
57
|
-
if refs:
|
|
58
|
-
lines.append(f"Evidence refs: {', '.join(str(ref) for ref in refs[:limit])}")
|
|
59
|
-
relations = result.get("relations") or []
|
|
60
|
-
if relations:
|
|
61
|
-
lines.append("Local relations:")
|
|
62
|
-
for relation in relations[:limit]:
|
|
63
|
-
relation_type = str(relation.get("relation_type") or "related")
|
|
64
|
-
target = str(relation.get("target_ref") or relation.get("target_asset_id") or "").strip()
|
|
65
|
-
evidence = str(relation.get("evidence") or "").strip()
|
|
66
|
-
suffix = f" — {evidence[:120]}" if evidence else ""
|
|
67
|
-
lines.append(f"- {relation_type}: {target}{suffix}")
|
|
68
|
-
return "\n".join(lines)
|
|
40
|
+
return str(routed.get("rendered") or "")
|
|
69
41
|
|
|
70
42
|
|
|
71
43
|
def _parse_metadata(metadata: str = "") -> dict:
|