nexo-brain 7.23.6 → 7.23.7
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/package.json +1 -1
- package/src/dashboard/app.py +25 -7
- package/src/db/__init__.py +2 -1
- package/src/db/_episodic.py +45 -7
- package/src/email_contract.py +106 -0
- package/src/server.py +119 -0
- package/src/tools_credentials.py +75 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.23.
|
|
3
|
+
"version": "7.23.7",
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.23.
|
|
3
|
+
"version": "7.23.7",
|
|
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/dashboard/app.py
CHANGED
|
@@ -31,7 +31,9 @@ if _PARENT not in sys.path:
|
|
|
31
31
|
|
|
32
32
|
from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
|
|
33
33
|
from cognitive_paths import resolve_cognitive_db
|
|
34
|
+
from email_contract import email_contract_snapshot
|
|
34
35
|
import paths
|
|
36
|
+
from tools_credentials import public_credential_records
|
|
35
37
|
|
|
36
38
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
37
39
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
@@ -1985,8 +1987,9 @@ async def api_artifacts():
|
|
|
1985
1987
|
@app.get("/api/email")
|
|
1986
1988
|
async def api_email_stats():
|
|
1987
1989
|
conn = _email_db()
|
|
1990
|
+
contract = email_contract_snapshot()
|
|
1988
1991
|
if not conn:
|
|
1989
|
-
return {"error": "Email DB not found", "stats": {}}
|
|
1992
|
+
return {"error": "Email DB not found", "stats": {}, "contract": contract}
|
|
1990
1993
|
total = conn.execute("SELECT COUNT(*) FROM emails").fetchone()[0]
|
|
1991
1994
|
processed = conn.execute("SELECT COUNT(*) FROM emails WHERE status='processed'").fetchone()[0]
|
|
1992
1995
|
pending = conn.execute("SELECT COUNT(*) FROM emails WHERE status='pending'").fetchone()[0]
|
|
@@ -1998,7 +2001,17 @@ async def api_email_stats():
|
|
|
1998
2001
|
"FROM emails GROUP BY thread_id ORDER BY last_email DESC LIMIT 15"
|
|
1999
2002
|
).fetchall()]
|
|
2000
2003
|
conn.close()
|
|
2001
|
-
return {
|
|
2004
|
+
return {
|
|
2005
|
+
"stats": {"total": total, "processed": processed, "pending": pending},
|
|
2006
|
+
"recent": recent,
|
|
2007
|
+
"threads": threads,
|
|
2008
|
+
"contract": contract,
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
@app.get("/api/email/contract")
|
|
2013
|
+
async def api_email_contract():
|
|
2014
|
+
return email_contract_snapshot()
|
|
2002
2015
|
|
|
2003
2016
|
|
|
2004
2017
|
# ---------------------------------------------------------------------------
|
|
@@ -2007,16 +2020,21 @@ async def api_email_stats():
|
|
|
2007
2020
|
|
|
2008
2021
|
@app.get("/api/credentials")
|
|
2009
2022
|
async def api_credentials():
|
|
2010
|
-
|
|
2011
|
-
conn = db.get_db()
|
|
2012
|
-
rows = conn.execute("SELECT service, key, notes, created_at, updated_at FROM credentials ORDER BY service, key").fetchall()
|
|
2013
|
-
creds = [dict(r) for r in rows]
|
|
2023
|
+
creds = public_credential_records()
|
|
2014
2024
|
services = {}
|
|
2015
2025
|
for c in creds:
|
|
2016
|
-
services.setdefault(c["service"], []).append(
|
|
2026
|
+
services.setdefault(c["service"], []).append(
|
|
2027
|
+
{"key": c["key"], "notes": c.get("notes", ""), "backend": c.get("backend", "db")}
|
|
2028
|
+
)
|
|
2017
2029
|
return {"credentials": creds, "services": services, "total": len(creds)}
|
|
2018
2030
|
|
|
2019
2031
|
|
|
2032
|
+
@app.get("/api/change-log/retention")
|
|
2033
|
+
async def api_change_log_retention():
|
|
2034
|
+
db = _db()
|
|
2035
|
+
return db.change_log_retention_policy()
|
|
2036
|
+
|
|
2037
|
+
|
|
2020
2038
|
# ---------------------------------------------------------------------------
|
|
2021
2039
|
# Backups
|
|
2022
2040
|
# ---------------------------------------------------------------------------
|
package/src/db/__init__.py
CHANGED
|
@@ -154,7 +154,8 @@ from db._entities import (
|
|
|
154
154
|
|
|
155
155
|
# Episodic memory
|
|
156
156
|
from db._episodic import (
|
|
157
|
-
cleanup_old_changes,
|
|
157
|
+
cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
|
|
158
|
+
log_change, search_changes, update_change_commit, auto_resolve_followups,
|
|
158
159
|
cleanup_old_decisions, log_decision, update_decision_outcome,
|
|
159
160
|
get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
|
|
160
161
|
cleanup_old_diaries, write_session_diary,
|
package/src/db/_episodic.py
CHANGED
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
"""NEXO DB — Episodic module."""
|
|
3
|
-
import datetime, time, json
|
|
3
|
+
import datetime, time, json, os
|
|
4
4
|
from db._core import get_db, now_epoch, _multi_word_like
|
|
5
5
|
from db._fts import fts_upsert, fts_search
|
|
6
6
|
|
|
7
7
|
# ── Change Log ───────────────────────────────────────────────────
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
DEFAULT_CHANGE_LOG_RETENTION_DAYS = 90
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def change_log_retention_days() -> int:
|
|
13
|
+
"""Return the configured change-log retention window in days."""
|
|
14
|
+
raw = os.environ.get("NEXO_CHANGE_LOG_RETENTION_DAYS", str(DEFAULT_CHANGE_LOG_RETENTION_DAYS))
|
|
15
|
+
try:
|
|
16
|
+
return max(1, int(raw))
|
|
17
|
+
except (TypeError, ValueError):
|
|
18
|
+
return DEFAULT_CHANGE_LOG_RETENTION_DAYS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def change_log_retention_policy() -> dict:
|
|
22
|
+
"""Public contract for change-log cleanup and its FTS side effects."""
|
|
23
|
+
return {
|
|
24
|
+
"retention_days": change_log_retention_days(),
|
|
25
|
+
"env": "NEXO_CHANGE_LOG_RETENTION_DAYS",
|
|
26
|
+
"default_retention_days": DEFAULT_CHANGE_LOG_RETENTION_DAYS,
|
|
27
|
+
"applies_to": ["change_log", "unified_search:source=change"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cleanup_old_changes(retention_days: int | None = None) -> int:
|
|
10
32
|
"""Delete change_log entries older than retention_days. Returns count deleted."""
|
|
11
33
|
conn = get_db()
|
|
34
|
+
days = change_log_retention_days() if retention_days is None else max(1, int(retention_days))
|
|
12
35
|
# Get IDs before deleting so we can clean FTS
|
|
13
36
|
ids = [str(r[0]) for r in conn.execute(
|
|
14
37
|
"SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
|
|
15
|
-
(f"-{
|
|
38
|
+
(f"-{days} days",)
|
|
16
39
|
).fetchall()]
|
|
17
40
|
cursor = conn.execute(
|
|
18
41
|
"DELETE FROM change_log WHERE created_at < datetime('now', ?)",
|
|
19
|
-
(f"-{
|
|
42
|
+
(f"-{days} days",)
|
|
20
43
|
)
|
|
21
44
|
for cid in ids:
|
|
22
45
|
conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
|
|
@@ -668,15 +691,30 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
|
|
|
668
691
|
# Excludes: cron jobs, auto-closed crons (0 heartbeats or "Minimal diary").
|
|
669
692
|
if include_automated:
|
|
670
693
|
source_clause = ""
|
|
694
|
+
source_params = ()
|
|
671
695
|
else:
|
|
696
|
+
automated_sources = (
|
|
697
|
+
"auto-close",
|
|
698
|
+
"cron",
|
|
699
|
+
"system",
|
|
700
|
+
"automation",
|
|
701
|
+
"deep-sleep",
|
|
702
|
+
"daily-self-audit",
|
|
703
|
+
"self-audit",
|
|
704
|
+
"watchdog",
|
|
705
|
+
"followup-runner",
|
|
706
|
+
"script",
|
|
707
|
+
)
|
|
708
|
+
placeholders = ",".join("?" for _ in automated_sources)
|
|
672
709
|
source_clause = (
|
|
673
710
|
" AND ("
|
|
674
|
-
" (source
|
|
711
|
+
f" (COALESCE(source, '') NOT IN ({placeholders}) AND summary NOT LIKE '[AUTO-%')"
|
|
675
712
|
" OR (source = 'auto-close'"
|
|
676
713
|
" AND mental_state NOT LIKE '%0 heartbeats%'"
|
|
677
714
|
" AND mental_state NOT LIKE '%Minimal diary%')"
|
|
678
715
|
")"
|
|
679
716
|
)
|
|
717
|
+
source_params = automated_sources
|
|
680
718
|
|
|
681
719
|
if session_id:
|
|
682
720
|
rows = conn.execute(
|
|
@@ -688,12 +726,12 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
|
|
|
688
726
|
f"SELECT * FROM session_diary "
|
|
689
727
|
f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
|
|
690
728
|
f"ORDER BY {_diary_order_sql()}",
|
|
691
|
-
domain_params
|
|
729
|
+
domain_params + source_params
|
|
692
730
|
).fetchall()
|
|
693
731
|
else:
|
|
694
732
|
rows = conn.execute(
|
|
695
733
|
f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY {_diary_order_sql()} LIMIT ?",
|
|
696
|
-
domain_params + (last_n,)
|
|
734
|
+
domain_params + source_params + (last_n,)
|
|
697
735
|
).fetchall()
|
|
698
736
|
return [dict(r) for r in rows]
|
|
699
737
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Email runtime contract: account config is separate from monitor events."""
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import paths
|
|
9
|
+
from db import get_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _table_exists(conn, table: str) -> bool:
|
|
13
|
+
row = conn.execute(
|
|
14
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
|
15
|
+
(table,),
|
|
16
|
+
).fetchone()
|
|
17
|
+
return row is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _count(conn, sql: str, params: tuple = ()) -> int:
|
|
21
|
+
try:
|
|
22
|
+
return int(conn.execute(sql, params).fetchone()[0])
|
|
23
|
+
except Exception:
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _email_event_db_path() -> Path:
|
|
28
|
+
return paths.nexo_email_dir() / "nexo-email.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _accounts_config_snapshot() -> dict:
|
|
32
|
+
conn = get_db()
|
|
33
|
+
exists = _table_exists(conn, "email_accounts")
|
|
34
|
+
out = {
|
|
35
|
+
"store": "nexo.db",
|
|
36
|
+
"table": "email_accounts",
|
|
37
|
+
"table_exists": exists,
|
|
38
|
+
"total": 0,
|
|
39
|
+
"enabled": 0,
|
|
40
|
+
"agent_accounts": 0,
|
|
41
|
+
"operator_accounts": 0,
|
|
42
|
+
"with_credentials": 0,
|
|
43
|
+
"missing_credentials": 0,
|
|
44
|
+
}
|
|
45
|
+
if not exists:
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
rows = [dict(row) for row in conn.execute("SELECT * FROM email_accounts").fetchall()]
|
|
49
|
+
out["total"] = len(rows)
|
|
50
|
+
out["enabled"] = sum(1 for row in rows if int(row.get("enabled") or 0) == 1)
|
|
51
|
+
out["agent_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "agent")
|
|
52
|
+
out["operator_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "operator")
|
|
53
|
+
out["with_credentials"] = sum(
|
|
54
|
+
1
|
|
55
|
+
for row in rows
|
|
56
|
+
if (row.get("credential_service") or "").strip() and (row.get("credential_key") or "").strip()
|
|
57
|
+
)
|
|
58
|
+
out["missing_credentials"] = out["total"] - out["with_credentials"]
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _event_store_snapshot(email_db_path: str | Path | None = None) -> dict:
|
|
63
|
+
path = Path(email_db_path) if email_db_path else _email_event_db_path()
|
|
64
|
+
out = {
|
|
65
|
+
"store": str(path),
|
|
66
|
+
"db_exists": path.is_file(),
|
|
67
|
+
"emails_table": False,
|
|
68
|
+
"email_events_table": False,
|
|
69
|
+
"total_emails": 0,
|
|
70
|
+
"total_events": 0,
|
|
71
|
+
"pending": 0,
|
|
72
|
+
"processed": 0,
|
|
73
|
+
}
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
conn = sqlite3.connect(str(path))
|
|
78
|
+
conn.row_factory = sqlite3.Row
|
|
79
|
+
try:
|
|
80
|
+
out["emails_table"] = _table_exists(conn, "emails")
|
|
81
|
+
out["email_events_table"] = _table_exists(conn, "email_events")
|
|
82
|
+
if out["emails_table"]:
|
|
83
|
+
out["total_emails"] = _count(conn, "SELECT COUNT(*) FROM emails")
|
|
84
|
+
out["pending"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='pending'")
|
|
85
|
+
out["processed"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='processed'")
|
|
86
|
+
if out["email_events_table"]:
|
|
87
|
+
out["total_events"] = _count(conn, "SELECT COUNT(*) FROM email_events")
|
|
88
|
+
finally:
|
|
89
|
+
conn.close()
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def email_contract_snapshot(email_db_path: str | Path | None = None) -> dict:
|
|
94
|
+
"""Return the explicit split between email account config and monitor events."""
|
|
95
|
+
accounts = _accounts_config_snapshot()
|
|
96
|
+
events = _event_store_snapshot(email_db_path)
|
|
97
|
+
return {
|
|
98
|
+
"contract": {
|
|
99
|
+
"layers": ["accounts_config", "event_store"],
|
|
100
|
+
"conflated": False,
|
|
101
|
+
"accounts_config": "nexo.db/email_accounts",
|
|
102
|
+
"event_store": "runtime/nexo-email/nexo-email.db",
|
|
103
|
+
},
|
|
104
|
+
"accounts_config": accounts,
|
|
105
|
+
"event_store": events,
|
|
106
|
+
}
|
package/src/server.py
CHANGED
|
@@ -105,7 +105,17 @@ from plugins.episodic_memory import handle_session_diary_read, handle_session_di
|
|
|
105
105
|
from plugins.cards import handle_card_match
|
|
106
106
|
from plugins.skills import handle_skill_match
|
|
107
107
|
from plugins.workflow import (
|
|
108
|
+
handle_goal_get,
|
|
109
|
+
handle_goal_list,
|
|
110
|
+
handle_goal_open,
|
|
111
|
+
handle_goal_update,
|
|
112
|
+
handle_workflow_compensation,
|
|
113
|
+
handle_workflow_get,
|
|
114
|
+
handle_workflow_handoff,
|
|
115
|
+
handle_workflow_list,
|
|
108
116
|
handle_workflow_open,
|
|
117
|
+
handle_workflow_replay,
|
|
118
|
+
handle_workflow_resume,
|
|
109
119
|
handle_workflow_update,
|
|
110
120
|
)
|
|
111
121
|
from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
|
|
@@ -816,6 +826,115 @@ def nexo_workflow_update(
|
|
|
816
826
|
)
|
|
817
827
|
|
|
818
828
|
|
|
829
|
+
@mcp.tool
|
|
830
|
+
def nexo_goal_open(
|
|
831
|
+
sid: str,
|
|
832
|
+
title: str,
|
|
833
|
+
objective: str = "",
|
|
834
|
+
parent_goal_id: str = "",
|
|
835
|
+
priority: str = "normal",
|
|
836
|
+
next_action: str = "",
|
|
837
|
+
success_signal: str = "",
|
|
838
|
+
owner: str = "",
|
|
839
|
+
shared_state: str = "{}",
|
|
840
|
+
) -> str:
|
|
841
|
+
"""Open a durable goal so objectives survive sessions."""
|
|
842
|
+
return handle_goal_open(
|
|
843
|
+
sid,
|
|
844
|
+
title,
|
|
845
|
+
objective,
|
|
846
|
+
parent_goal_id,
|
|
847
|
+
priority,
|
|
848
|
+
next_action,
|
|
849
|
+
success_signal,
|
|
850
|
+
owner,
|
|
851
|
+
shared_state,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@mcp.tool
|
|
856
|
+
def nexo_goal_update(
|
|
857
|
+
goal_id: str,
|
|
858
|
+
status: str = "",
|
|
859
|
+
title: str = "",
|
|
860
|
+
objective: str = "",
|
|
861
|
+
parent_goal_id: str = "",
|
|
862
|
+
next_action: str = "",
|
|
863
|
+
success_signal: str = "",
|
|
864
|
+
blocker_reason: str = "",
|
|
865
|
+
owner: str = "",
|
|
866
|
+
shared_state: str = "",
|
|
867
|
+
) -> str:
|
|
868
|
+
"""Update a durable goal with active/blocked/completed state."""
|
|
869
|
+
return handle_goal_update(
|
|
870
|
+
goal_id,
|
|
871
|
+
status,
|
|
872
|
+
title,
|
|
873
|
+
objective,
|
|
874
|
+
parent_goal_id,
|
|
875
|
+
next_action,
|
|
876
|
+
success_signal,
|
|
877
|
+
blocker_reason,
|
|
878
|
+
owner,
|
|
879
|
+
shared_state,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@mcp.tool
|
|
884
|
+
def nexo_goal_get(goal_id: str, include_runs: bool = False) -> str:
|
|
885
|
+
"""Read one durable goal and optionally include linked workflow runs."""
|
|
886
|
+
return handle_goal_get(goal_id, include_runs)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
@mcp.tool
|
|
890
|
+
def nexo_goal_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
|
|
891
|
+
"""List durable goals."""
|
|
892
|
+
return handle_goal_list(status, include_closed, limit)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@mcp.tool
|
|
896
|
+
def nexo_workflow_get(run_id: str, include_steps: bool = True, checkpoint_limit: int = 8) -> str:
|
|
897
|
+
"""Read the full durable workflow state."""
|
|
898
|
+
return handle_workflow_get(run_id, include_steps, checkpoint_limit)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
@mcp.tool
|
|
902
|
+
def nexo_workflow_handoff(
|
|
903
|
+
run_id: str,
|
|
904
|
+
actor: str,
|
|
905
|
+
next_action: str = "",
|
|
906
|
+
handoff_note: str = "",
|
|
907
|
+
shared_state: str = "",
|
|
908
|
+
new_owner: str = "",
|
|
909
|
+
) -> str:
|
|
910
|
+
"""Record a durable workflow handoff."""
|
|
911
|
+
return handle_workflow_handoff(run_id, actor, next_action, handoff_note, shared_state, new_owner)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
@mcp.tool
|
|
915
|
+
def nexo_workflow_compensation(run_id: str, checkpoint_limit: int = 10) -> str:
|
|
916
|
+
"""Return the compensation plan for a partially completed workflow."""
|
|
917
|
+
return handle_workflow_compensation(run_id, checkpoint_limit)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@mcp.tool
|
|
921
|
+
def nexo_workflow_resume(run_id: str) -> str:
|
|
922
|
+
"""Summarize the next actionable step for a workflow run."""
|
|
923
|
+
return handle_workflow_resume(run_id)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@mcp.tool
|
|
927
|
+
def nexo_workflow_replay(run_id: str, limit: int = 20) -> str:
|
|
928
|
+
"""Replay recent checkpoints for a workflow run."""
|
|
929
|
+
return handle_workflow_replay(run_id, limit)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
@mcp.tool
|
|
933
|
+
def nexo_workflow_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
|
|
934
|
+
"""List durable workflow runs."""
|
|
935
|
+
return handle_workflow_list(status, include_closed, limit)
|
|
936
|
+
|
|
937
|
+
|
|
819
938
|
@mcp.tool
|
|
820
939
|
def nexo_status(keyword: str = "") -> str:
|
|
821
940
|
"""List active sessions. Filter by keyword if provided."""
|
package/src/tools_credentials.py
CHANGED
|
@@ -18,12 +18,73 @@ before persisting. The agent should never mint a BYOK entry on its own.
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
+
import re
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
|
|
23
24
|
from db import create_credential, update_credential, delete_credential, get_credential, list_credentials, get_db
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
BYOK_SERVICE = "byok"
|
|
28
|
+
REDACTED_SECRET_NOTE = "[redacted: secret-like note]"
|
|
29
|
+
SECRET_NOTE_PATTERNS = (
|
|
30
|
+
re.compile(r"\b(?:npm|ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b"),
|
|
31
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
|
32
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
|
|
33
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"),
|
|
34
|
+
re.compile(r"\b(?:api[_-]?key|secret|token|password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE),
|
|
35
|
+
re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b", re.IGNORECASE),
|
|
36
|
+
re.compile(r"\b[A-Za-z0-9+/=_-]{40,}\b"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def credential_note_has_secret(notes: str) -> bool:
|
|
41
|
+
"""Detect notes that appear to contain a credential value."""
|
|
42
|
+
clean = (notes or "").strip()
|
|
43
|
+
if not clean:
|
|
44
|
+
return False
|
|
45
|
+
return any(pattern.search(clean) for pattern in SECRET_NOTE_PATTERNS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_credential_notes(notes: str) -> str:
|
|
49
|
+
"""Return notes safe for list/dashboard surfaces."""
|
|
50
|
+
clean = notes or ""
|
|
51
|
+
if credential_note_has_secret(clean):
|
|
52
|
+
return REDACTED_SECRET_NOTE
|
|
53
|
+
return clean
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def public_credential_records(service: str = "") -> list[dict]:
|
|
57
|
+
"""List credential metadata from DB and BYOK without leaking values."""
|
|
58
|
+
requested = (service or "").strip()
|
|
59
|
+
records: list[dict] = []
|
|
60
|
+
|
|
61
|
+
if requested != BYOK_SERVICE:
|
|
62
|
+
for row in list_credentials(requested if requested else None):
|
|
63
|
+
records.append(
|
|
64
|
+
{
|
|
65
|
+
"service": row.get("service", ""),
|
|
66
|
+
"key": row.get("key", ""),
|
|
67
|
+
"notes": redact_credential_notes(row.get("notes") or ""),
|
|
68
|
+
"created_at": row.get("created_at", ""),
|
|
69
|
+
"updated_at": row.get("updated_at", ""),
|
|
70
|
+
"backend": "db",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if requested in ("", BYOK_SERVICE):
|
|
75
|
+
for row in _byok_get(""):
|
|
76
|
+
records.append(
|
|
77
|
+
{
|
|
78
|
+
"service": row.get("service", BYOK_SERVICE),
|
|
79
|
+
"key": row.get("key", ""),
|
|
80
|
+
"notes": redact_credential_notes(row.get("notes") or ""),
|
|
81
|
+
"created_at": "",
|
|
82
|
+
"updated_at": "",
|
|
83
|
+
"backend": "byok_local",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return records
|
|
27
88
|
|
|
28
89
|
|
|
29
90
|
def _credential_exists(service: str, key: str) -> bool:
|
|
@@ -193,6 +254,11 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
|
|
|
193
254
|
"connect the provider there (the UI validates the key with the "
|
|
194
255
|
"provider before saving)."
|
|
195
256
|
)
|
|
257
|
+
if credential_note_has_secret(notes):
|
|
258
|
+
return (
|
|
259
|
+
"ERROR: Credential notes look like they contain a secret. "
|
|
260
|
+
"Put the secret in value, and keep notes operational only."
|
|
261
|
+
)
|
|
196
262
|
# ── R02 (Fase 2 Protocol Enforcer): reject exact (service, key) duplicates ──
|
|
197
263
|
force_flag = str(force or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
198
264
|
if not force_flag and _credential_exists(service, key):
|
|
@@ -223,6 +289,11 @@ def handle_credential_update(service: str, key: str, value: str = '', notes: str
|
|
|
223
289
|
"ERROR: BYOK credentials are not editable from the agent. "
|
|
224
290
|
"Ask the user to update the connection in NEXO Desktop > Settings > Connections."
|
|
225
291
|
)
|
|
292
|
+
if credential_note_has_secret(notes):
|
|
293
|
+
return (
|
|
294
|
+
"ERROR: Credential notes look like they contain a secret. "
|
|
295
|
+
"Put the secret in value, and keep notes operational only."
|
|
296
|
+
)
|
|
226
297
|
result = update_credential(
|
|
227
298
|
service,
|
|
228
299
|
key,
|
|
@@ -264,17 +335,14 @@ def handle_credential_list(service: str = '') -> str:
|
|
|
264
335
|
Listing without ``service`` only returns DB entries (the historical
|
|
265
336
|
behaviour). Pass ``service='byok'`` to list the BYOK filesystem store.
|
|
266
337
|
"""
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
label = "BYOK"
|
|
270
|
-
else:
|
|
271
|
-
results = list_credentials(service if service else None)
|
|
272
|
-
label = service if service else "ALL"
|
|
338
|
+
results = public_credential_records(service)
|
|
339
|
+
label = service if service else "ALL"
|
|
273
340
|
if not results:
|
|
274
341
|
return f"CREDENTIALS {label.upper()}: No entries."
|
|
275
342
|
lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
|
|
276
343
|
for r in results:
|
|
277
344
|
notes = r.get("notes") or ""
|
|
278
345
|
suffix = f" — {notes}" if notes else ""
|
|
279
|
-
|
|
346
|
+
backend = r.get("backend") or "db"
|
|
347
|
+
lines.append(f" {r['service']}/{r['key']} ({backend}){suffix}")
|
|
280
348
|
return "\n".join(lines)
|