nexo-brain 7.17.5 → 7.17.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/README.md +5 -1
- package/package.json +1 -1
- package/src/runtime_power.py +10 -3
- package/src/scripts/jargon_first_response.py +239 -0
- package/src/scripts/nexo-catchup.py +85 -1
- package/src/scripts/nexo-tcc-approve.sh +162 -18
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.17.
|
|
3
|
+
"version": "7.17.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/README.md
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
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.17.
|
|
21
|
+
Version `7.17.7` is the current packaged-runtime line. Patch release over v7.17.6 - macOS TCC privacy denials now become a guided Full Disk Access permission state instead of an unexplained cron failure, with Desktop-ready status written for user action.
|
|
22
|
+
|
|
23
|
+
Previously in `7.17.6`: patch release over v7.17.5 - cron health diagnostics are clearer for macOS TCC approval, and catch-up fallback executions now stay visible in `cron_runs` even on legacy or partially migrated runtimes.
|
|
24
|
+
|
|
25
|
+
Previously in `7.17.5`: patch release over v7.17.4 - `nexo --version --json` now returns machine-readable update status so NEXO Desktop can populate the Updates panel without scraping slower human output.
|
|
22
26
|
|
|
23
27
|
Previously in `7.17.4`: corrective patch over v7.17.3 - automation runners now keep full NEXO discipline for real background agents while strict JSON children stay clean, and runtime doctor/metrics expose caller coverage and Guardian injection telemetry instead of hiding blind spots.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.17.
|
|
3
|
+
"version": "7.17.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/runtime_power.py
CHANGED
|
@@ -550,7 +550,12 @@ def _tail_has_permission_denial(log_file: Path) -> bool:
|
|
|
550
550
|
size = fh.tell()
|
|
551
551
|
fh.seek(max(size - 4096, 0))
|
|
552
552
|
tail = fh.read().decode("utf-8", errors="ignore")
|
|
553
|
-
|
|
553
|
+
lowered = tail.lower()
|
|
554
|
+
return (
|
|
555
|
+
"operation not permitted" in lowered
|
|
556
|
+
or "authorization denied" in lowered
|
|
557
|
+
or "unable to open database" in lowered and "com.apple.tcc/tcc.db" in lowered
|
|
558
|
+
)
|
|
554
559
|
except Exception:
|
|
555
560
|
return False
|
|
556
561
|
|
|
@@ -568,10 +573,12 @@ def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
|
|
|
568
573
|
|
|
569
574
|
logs_dir = paths.logs_dir()
|
|
570
575
|
if logs_dir.is_dir():
|
|
571
|
-
|
|
576
|
+
log_files = list(logs_dir.glob("*-stderr.log"))
|
|
577
|
+
log_files.extend(logs_dir.glob("tcc-auto-approve.log"))
|
|
578
|
+
for log_file in sorted(log_files):
|
|
572
579
|
if _tail_has_permission_denial(log_file):
|
|
573
580
|
reasons.append(
|
|
574
|
-
f"Recent background job
|
|
581
|
+
f"Recent background job hit a macOS privacy denial ({log_file.name})"
|
|
575
582
|
)
|
|
576
583
|
break
|
|
577
584
|
return reasons
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""First-response jargon checker (Communication Guardrail enforcement).
|
|
3
|
+
|
|
4
|
+
Implements followup NF-DS-32D6E12E: a linter that scans the FIRST visible
|
|
5
|
+
message the agent emits in a turn for NEXO internal jargon that violates
|
|
6
|
+
the Communication Guardrail in CLAUDE.md.
|
|
7
|
+
|
|
8
|
+
Token list (case-insensitive substring match), taken verbatim from the
|
|
9
|
+
followup spec:
|
|
10
|
+
|
|
11
|
+
Learning #, protocol debt, cortex eval, runtime-core, guard_check,
|
|
12
|
+
heartbeat, pre-emptive guard, enforcer, task_open, task_close, NF-
|
|
13
|
+
|
|
14
|
+
Use as:
|
|
15
|
+
|
|
16
|
+
* Library:
|
|
17
|
+
from src.scripts.jargon_first_response import scan_text, register_debt_if_violations
|
|
18
|
+
|
|
19
|
+
* CLI (manual review of a recent reply):
|
|
20
|
+
echo "Learning #42 applied via task_open" \
|
|
21
|
+
| python3 src/scripts/jargon_first_response.py --stdin --session-id NEXO-SID
|
|
22
|
+
|
|
23
|
+
* Future hook integration (PostToolUse / Stop) loads the transcript with
|
|
24
|
+
`transcript_utils.load_transcript` and pipes the latest assistant
|
|
25
|
+
message through `register_debt_if_violations`. Hook wiring is left as
|
|
26
|
+
a separate change so the linter ships independently testable.
|
|
27
|
+
|
|
28
|
+
Exit codes (CLI):
|
|
29
|
+
0 — no violations detected (or only allowed because user requested detail)
|
|
30
|
+
1 — at least one prohibited token found
|
|
31
|
+
2 — invocation error (missing input)
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Iterable, List, Sequence
|
|
41
|
+
|
|
42
|
+
# Order matters only for stable output. Longer / multi-word phrases first
|
|
43
|
+
# so the snippet boundary is clearer in the matches view.
|
|
44
|
+
PROHIBITED_TOKENS: Sequence[str] = (
|
|
45
|
+
"Learning #",
|
|
46
|
+
"protocol debt",
|
|
47
|
+
"cortex eval",
|
|
48
|
+
"runtime-core",
|
|
49
|
+
"guard_check",
|
|
50
|
+
"pre-emptive guard",
|
|
51
|
+
"enforcer",
|
|
52
|
+
"task_open",
|
|
53
|
+
"task_close",
|
|
54
|
+
"heartbeat",
|
|
55
|
+
"NF-",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Heuristic signals from the *user* message that mean "operator asked for
|
|
59
|
+
# technical detail, jargon is allowed in the reply". The first answer
|
|
60
|
+
# only counts as a guardrail violation when none of these are present.
|
|
61
|
+
_USER_DETAIL_PATTERNS = (
|
|
62
|
+
re.compile(r"\bdebugg(?:ing|er)?\b", re.IGNORECASE),
|
|
63
|
+
re.compile(r"\bstack\s*trace\b", re.IGNORECASE),
|
|
64
|
+
re.compile(r"\b(?:protocol\s+debt|guard_check|task_open|task_close|enforcer|heartbeat)\b", re.IGNORECASE),
|
|
65
|
+
re.compile(r"\b(?:nf-[a-z0-9-]+|learning\s+#?\d+)\b", re.IGNORECASE),
|
|
66
|
+
re.compile(r"\b(internal|runtime|architecture|cortex|drive)\b", re.IGNORECASE),
|
|
67
|
+
re.compile(r"\b(?:explica|explain|deep[\s-]*dive|d[ée]tailled|d[ée]tail)\b", re.IGNORECASE),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _first_visible_paragraph(text: str) -> str:
|
|
72
|
+
"""Return the first ~600 chars of operator-visible text.
|
|
73
|
+
|
|
74
|
+
Strips leading whitespace, blank lines, and ignores fenced code blocks
|
|
75
|
+
at the very top (release notes / code-only first messages are not
|
|
76
|
+
"first response prose" for the guardrail purpose).
|
|
77
|
+
"""
|
|
78
|
+
if not text:
|
|
79
|
+
return ""
|
|
80
|
+
cleaned = text.lstrip()
|
|
81
|
+
if cleaned.startswith("```"):
|
|
82
|
+
end = cleaned.find("```", 3)
|
|
83
|
+
if end != -1:
|
|
84
|
+
cleaned = cleaned[end + 3 :].lstrip()
|
|
85
|
+
# First two paragraphs is usually plenty for the linter; longer
|
|
86
|
+
# replies have time to recover into plain language.
|
|
87
|
+
paragraphs = re.split(r"\n\s*\n", cleaned)
|
|
88
|
+
head = "\n\n".join(paragraphs[:2])
|
|
89
|
+
return head[:600]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def user_requested_detail(user_message: str) -> bool:
|
|
93
|
+
"""True if the operator explicitly asked for technical / NEXO-internal detail."""
|
|
94
|
+
if not user_message:
|
|
95
|
+
return False
|
|
96
|
+
return any(pat.search(user_message) for pat in _USER_DETAIL_PATTERNS)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def scan_text(text: str, *, tokens: Iterable[str] = PROHIBITED_TOKENS) -> List[dict]:
|
|
100
|
+
"""Return a list of `{token, index, snippet}` for each prohibited match."""
|
|
101
|
+
if not text:
|
|
102
|
+
return []
|
|
103
|
+
target = _first_visible_paragraph(text)
|
|
104
|
+
if not target:
|
|
105
|
+
return []
|
|
106
|
+
lowered = target.lower()
|
|
107
|
+
found: List[dict] = []
|
|
108
|
+
for token in tokens:
|
|
109
|
+
needle = token.lower()
|
|
110
|
+
start = 0
|
|
111
|
+
while True:
|
|
112
|
+
idx = lowered.find(needle, start)
|
|
113
|
+
if idx < 0:
|
|
114
|
+
break
|
|
115
|
+
snippet_start = max(0, idx - 25)
|
|
116
|
+
snippet_end = min(len(target), idx + len(token) + 25)
|
|
117
|
+
found.append({
|
|
118
|
+
"token": token,
|
|
119
|
+
"index": idx,
|
|
120
|
+
"snippet": target[snippet_start:snippet_end].replace("\n", " "),
|
|
121
|
+
})
|
|
122
|
+
start = idx + max(1, len(needle))
|
|
123
|
+
found.sort(key=lambda row: row["index"])
|
|
124
|
+
return found
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def register_debt_if_violations(
|
|
128
|
+
session_id: str,
|
|
129
|
+
assistant_text: str,
|
|
130
|
+
*,
|
|
131
|
+
user_message: str = "",
|
|
132
|
+
task_id: str = "",
|
|
133
|
+
evidence_prefix: str = "",
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""Register a protocol_debt of type `communication_guardrail` when the
|
|
136
|
+
first response uses prohibited NEXO jargon and the user did NOT ask
|
|
137
|
+
for technical detail.
|
|
138
|
+
|
|
139
|
+
Returns a result dict with keys:
|
|
140
|
+
- `violations`: list of matches from `scan_text`
|
|
141
|
+
- `skipped`: bool — true if checker did not register (no SID, user
|
|
142
|
+
requested detail, or no violations)
|
|
143
|
+
- `debt_id`: int|None — id of the created debt, when applicable
|
|
144
|
+
- `reason`: short human-readable status
|
|
145
|
+
"""
|
|
146
|
+
matches = scan_text(assistant_text)
|
|
147
|
+
if not matches:
|
|
148
|
+
return {"violations": [], "skipped": True, "debt_id": None, "reason": "no_violations"}
|
|
149
|
+
if user_requested_detail(user_message):
|
|
150
|
+
return {"violations": matches, "skipped": True, "debt_id": None, "reason": "user_requested_detail"}
|
|
151
|
+
sid = (session_id or "").strip()
|
|
152
|
+
if not sid:
|
|
153
|
+
return {"violations": matches, "skipped": True, "debt_id": None, "reason": "missing_session_id"}
|
|
154
|
+
try:
|
|
155
|
+
# Local import keeps this module importable in isolated test
|
|
156
|
+
# contexts (no NEXO DB) when callers only need `scan_text`.
|
|
157
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
158
|
+
from db import create_protocol_debt # type: ignore
|
|
159
|
+
except Exception as err: # pragma: no cover — defensive in tests
|
|
160
|
+
return {"violations": matches, "skipped": True, "debt_id": None, "reason": f"db_unavailable:{err}"}
|
|
161
|
+
evidence_lines = [evidence_prefix] if evidence_prefix else []
|
|
162
|
+
for match in matches[:5]:
|
|
163
|
+
evidence_lines.append(f"- '{match['token']}' @ {match['index']}: ...{match['snippet']}...")
|
|
164
|
+
evidence = "\n".join(line for line in evidence_lines if line)[:4000]
|
|
165
|
+
debt = create_protocol_debt(
|
|
166
|
+
sid,
|
|
167
|
+
"communication_guardrail",
|
|
168
|
+
severity="warn",
|
|
169
|
+
task_id=task_id,
|
|
170
|
+
evidence=evidence,
|
|
171
|
+
)
|
|
172
|
+
return {
|
|
173
|
+
"violations": matches,
|
|
174
|
+
"skipped": False,
|
|
175
|
+
"debt_id": debt.get("id") if isinstance(debt, dict) else None,
|
|
176
|
+
"reason": "debt_registered",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _read_text_from_args(args: argparse.Namespace) -> str:
|
|
181
|
+
if args.text is not None:
|
|
182
|
+
return args.text
|
|
183
|
+
if args.stdin:
|
|
184
|
+
return sys.stdin.read()
|
|
185
|
+
if args.file:
|
|
186
|
+
return Path(args.file).read_text(encoding="utf-8")
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
191
|
+
parser = argparse.ArgumentParser(description="First-response jargon checker (NEXO Communication Guardrail).")
|
|
192
|
+
parser.add_argument("--text", help="Assistant text to scan (inline).")
|
|
193
|
+
parser.add_argument("--stdin", action="store_true", help="Read assistant text from stdin.")
|
|
194
|
+
parser.add_argument("--file", help="Read assistant text from file.")
|
|
195
|
+
parser.add_argument("--user-message", default="", help="Optional user message preceding the reply.")
|
|
196
|
+
parser.add_argument("--session-id", default=os.environ.get("NEXO_SID", ""), help="Session ID for protocol_debt registration.")
|
|
197
|
+
parser.add_argument("--task-id", default="", help="Optional task ID to attach to the debt.")
|
|
198
|
+
parser.add_argument("--register-debt", action="store_true", help="Register a protocol_debt when violations are found.")
|
|
199
|
+
parser.add_argument("--evidence-prefix", default="", help="Optional prefix for the evidence string.")
|
|
200
|
+
args = parser.parse_args(argv)
|
|
201
|
+
|
|
202
|
+
text = _read_text_from_args(args)
|
|
203
|
+
if not text:
|
|
204
|
+
parser.error("provide --text, --stdin, or --file")
|
|
205
|
+
return 2
|
|
206
|
+
|
|
207
|
+
if args.register_debt:
|
|
208
|
+
result = register_debt_if_violations(
|
|
209
|
+
args.session_id,
|
|
210
|
+
text,
|
|
211
|
+
user_message=args.user_message,
|
|
212
|
+
task_id=args.task_id,
|
|
213
|
+
evidence_prefix=args.evidence_prefix,
|
|
214
|
+
)
|
|
215
|
+
violations = result["violations"]
|
|
216
|
+
else:
|
|
217
|
+
violations = scan_text(text)
|
|
218
|
+
result = {
|
|
219
|
+
"violations": violations,
|
|
220
|
+
"skipped": True,
|
|
221
|
+
"debt_id": None,
|
|
222
|
+
"reason": "dry_run",
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if not violations:
|
|
226
|
+
print("[jargon-checker] OK — no prohibited tokens in first response.")
|
|
227
|
+
return 0
|
|
228
|
+
print(f"[jargon-checker] {len(violations)} match(es) detected:")
|
|
229
|
+
for match in violations:
|
|
230
|
+
print(f" - {match['token']!r} @ {match['index']} …{match['snippet']}…")
|
|
231
|
+
if result.get("debt_id"):
|
|
232
|
+
print(f"[jargon-checker] protocol_debt registered id={result['debt_id']} reason=communication_guardrail")
|
|
233
|
+
elif args.register_debt:
|
|
234
|
+
print(f"[jargon-checker] no debt registered: {result.get('reason')}")
|
|
235
|
+
return 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
|
@@ -8,9 +8,10 @@ Legacy .catchup-state.json is now only a fallback for pre-wrapper history.
|
|
|
8
8
|
import fcntl
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import sqlite3
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
|
-
from datetime import datetime
|
|
14
|
+
from datetime import datetime, timezone
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
|
|
@@ -93,6 +94,85 @@ def _resolve_runtime_command(script_type: str) -> str:
|
|
|
93
94
|
return NEXO_PYTHON
|
|
94
95
|
|
|
95
96
|
|
|
97
|
+
def _cron_run_db_path() -> Path:
|
|
98
|
+
return paths.data_dir() / "nexo.db"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _utc_timestamp() -> str:
|
|
102
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _summarize_output(stdout: str, stderr: str) -> tuple[str, str]:
|
|
106
|
+
combined = "\n".join(part for part in (stdout or "", stderr or "") if part)
|
|
107
|
+
lines = [line.strip() for line in combined.splitlines() if line.strip()]
|
|
108
|
+
summary = (lines[-1] if lines else "")[:500]
|
|
109
|
+
error = ""
|
|
110
|
+
for line in reversed(lines):
|
|
111
|
+
lowered = line.lower()
|
|
112
|
+
if any(marker in lowered for marker in ("error", "exception", "fail", "traceback")):
|
|
113
|
+
error = line[:500]
|
|
114
|
+
break
|
|
115
|
+
return summary, error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _start_direct_cron_run(cron_id: str) -> dict | None:
|
|
119
|
+
"""Record a catch-up run when the shell wrapper is unavailable.
|
|
120
|
+
|
|
121
|
+
Normal installs use nexo-cron-wrapper.sh as the single writer. This
|
|
122
|
+
fallback exists for legacy/partially-migrated runtimes where catch-up can
|
|
123
|
+
still execute a script directly; without it, the run only updates
|
|
124
|
+
.catchup-state.json and stays invisible to cron health.
|
|
125
|
+
"""
|
|
126
|
+
db_path = _cron_run_db_path()
|
|
127
|
+
started_at = _utc_timestamp()
|
|
128
|
+
try:
|
|
129
|
+
conn = sqlite3.connect(str(db_path))
|
|
130
|
+
try:
|
|
131
|
+
exists = conn.execute(
|
|
132
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='cron_runs'"
|
|
133
|
+
).fetchone()
|
|
134
|
+
if not exists:
|
|
135
|
+
return None
|
|
136
|
+
cur = conn.execute(
|
|
137
|
+
"INSERT INTO cron_runs (cron_id, started_at, ended_at) VALUES (?, ?, NULL)",
|
|
138
|
+
(cron_id, started_at),
|
|
139
|
+
)
|
|
140
|
+
conn.commit()
|
|
141
|
+
return {"id": cur.lastrowid, "started_at": started_at}
|
|
142
|
+
finally:
|
|
143
|
+
conn.close()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
log(f" WARNING: could not start direct cron_runs record for {cron_id}: {e}")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _finish_direct_cron_run(record: dict | None, cron_id: str, exit_code: int, stdout: str = "", stderr: str = ""):
|
|
150
|
+
if not record:
|
|
151
|
+
return
|
|
152
|
+
ended_at = _utc_timestamp()
|
|
153
|
+
summary, error = _summarize_output(stdout, stderr)
|
|
154
|
+
if exit_code != 0 and not error:
|
|
155
|
+
error = (stderr or stdout or f"exit {exit_code}")[:500]
|
|
156
|
+
db_path = _cron_run_db_path()
|
|
157
|
+
try:
|
|
158
|
+
conn = sqlite3.connect(str(db_path))
|
|
159
|
+
try:
|
|
160
|
+
conn.execute(
|
|
161
|
+
"""
|
|
162
|
+
UPDATE cron_runs
|
|
163
|
+
SET ended_at=?, exit_code=?, summary=?, error=?,
|
|
164
|
+
duration_secs=(julianday(?) - julianday(started_at)) * 86400.0
|
|
165
|
+
WHERE id=?
|
|
166
|
+
""",
|
|
167
|
+
(ended_at, int(exit_code), summary, error, ended_at, int(record["id"])),
|
|
168
|
+
)
|
|
169
|
+
conn.commit()
|
|
170
|
+
finally:
|
|
171
|
+
conn.close()
|
|
172
|
+
except Exception as e:
|
|
173
|
+
log(f" WARNING: could not finish direct cron_runs record for {cron_id}: {e}")
|
|
174
|
+
|
|
175
|
+
|
|
96
176
|
def _acquire_lock():
|
|
97
177
|
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
98
178
|
handle = LOCK_FILE.open("w")
|
|
@@ -150,12 +230,14 @@ def run_task(candidate: dict, state: dict) -> bool:
|
|
|
150
230
|
command = [runtime_cmd, str(script_path)]
|
|
151
231
|
|
|
152
232
|
log(f" RUNNING {name}: {script_name}")
|
|
233
|
+
direct_record = None if WRAPPER.exists() else _start_direct_cron_run(name)
|
|
153
234
|
try:
|
|
154
235
|
result = subprocess.run(
|
|
155
236
|
command,
|
|
156
237
|
capture_output=True, text=True, timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
157
238
|
env={**os.environ, "HOME": str(HOME), "NEXO_CATCHUP": "1"}
|
|
158
239
|
)
|
|
240
|
+
_finish_direct_cron_run(direct_record, name, result.returncode, result.stdout, result.stderr)
|
|
159
241
|
if result.returncode == 0:
|
|
160
242
|
log(f" OK {name} (exit 0)")
|
|
161
243
|
state[name] = datetime.now().isoformat()
|
|
@@ -167,9 +249,11 @@ def run_task(candidate: dict, state: dict) -> bool:
|
|
|
167
249
|
log(f" stderr: {result.stderr[:300]}")
|
|
168
250
|
return False
|
|
169
251
|
except subprocess.TimeoutExpired:
|
|
252
|
+
_finish_direct_cron_run(direct_record, name, 124, stderr=f"TIMEOUT after {AUTOMATION_SUBPROCESS_TIMEOUT}s")
|
|
170
253
|
log(f" TIMEOUT {name} ({AUTOMATION_SUBPROCESS_TIMEOUT}s)")
|
|
171
254
|
return False
|
|
172
255
|
except Exception as e:
|
|
256
|
+
_finish_direct_cron_run(direct_record, name, 1, stderr=str(e))
|
|
173
257
|
log(f" ERROR {name}: {e}")
|
|
174
258
|
return False
|
|
175
259
|
|
|
@@ -25,8 +25,141 @@ TCC_DB="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
|
|
|
25
25
|
VERSIONS_DIR="$HOME/.local/share/claude/versions"
|
|
26
26
|
MARKER_DIR="$NEXO_HOME/runtime/data/.tcc-approved"
|
|
27
27
|
LOG="$NEXO_HOME/runtime/logs/tcc-auto-approve.log"
|
|
28
|
+
FDA_STATE="$NEXO_HOME/runtime/state/full-disk-access-required.json"
|
|
28
29
|
|
|
29
|
-
mkdir -p "$MARKER_DIR" "$(dirname "$LOG")"
|
|
30
|
+
mkdir -p "$MARKER_DIR" "$(dirname "$LOG")" "$(dirname "$FDA_STATE")"
|
|
31
|
+
|
|
32
|
+
FAILED=0
|
|
33
|
+
FDA_REQUIRED=0
|
|
34
|
+
APPROVED_VERSIONS=0
|
|
35
|
+
PYTHON_APPROVED=0
|
|
36
|
+
FDA_REASON="macOS blocked tcc-approve from opening the user TCC database. Grant Full Disk Access to /bin/bash and NEXO Desktop, then retry background permission setup."
|
|
37
|
+
|
|
38
|
+
log_line() {
|
|
39
|
+
echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
is_fda_error() {
|
|
43
|
+
local text
|
|
44
|
+
text="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
|
45
|
+
[[ "$text" == *"authorization denied"* ]] && return 0
|
|
46
|
+
[[ "$text" == *"operation not permitted"* ]] && return 0
|
|
47
|
+
[[ "$text" == *"unable to open database"* && "$text" == *"com.apple.tcc/tcc.db"* ]] && return 0
|
|
48
|
+
[[ "$text" == *"privacy"* && "$text" == *"com.apple.tcc"* ]] && return 0
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
python_for_state() {
|
|
53
|
+
local candidate
|
|
54
|
+
if [ -n "${NEXO_CODE:-}" ]; then
|
|
55
|
+
candidate="$(dirname "$NEXO_CODE")/.venv/bin/python"
|
|
56
|
+
[ -x "$candidate" ] && { echo "$candidate"; return 0; }
|
|
57
|
+
candidate="$(dirname "$NEXO_CODE")/.venv/bin/python3"
|
|
58
|
+
[ -x "$candidate" ] && { echo "$candidate"; return 0; }
|
|
59
|
+
fi
|
|
60
|
+
command -v python3 2>/dev/null || true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
record_full_disk_access_required() {
|
|
64
|
+
local py
|
|
65
|
+
py="$(python_for_state)"
|
|
66
|
+
|
|
67
|
+
cat > "$FDA_STATE" <<EOF
|
|
68
|
+
{
|
|
69
|
+
"status": "later",
|
|
70
|
+
"source": "tcc-approve",
|
|
71
|
+
"updated_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
|
|
72
|
+
"reasons": [
|
|
73
|
+
"$FDA_REASON"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
EOF
|
|
77
|
+
|
|
78
|
+
[ -n "$py" ] || return 0
|
|
79
|
+
NEXO_FDA_REASON="$FDA_REASON" NEXO_HOME="$NEXO_HOME" "$py" <<'PY'
|
|
80
|
+
import json
|
|
81
|
+
import os
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
85
|
+
reason = os.environ.get("NEXO_FDA_REASON", "").strip()
|
|
86
|
+
targets = [nexo_home / "personal" / "config" / "schedule.json"]
|
|
87
|
+
legacy = nexo_home / "config" / "schedule.json"
|
|
88
|
+
if legacy.exists():
|
|
89
|
+
targets.append(legacy)
|
|
90
|
+
|
|
91
|
+
seen = set()
|
|
92
|
+
for target in targets:
|
|
93
|
+
key = str(target.resolve(strict=False))
|
|
94
|
+
if key in seen:
|
|
95
|
+
continue
|
|
96
|
+
seen.add(key)
|
|
97
|
+
try:
|
|
98
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
data = {}
|
|
100
|
+
if target.exists():
|
|
101
|
+
try:
|
|
102
|
+
parsed = json.loads(target.read_text(encoding="utf-8"))
|
|
103
|
+
if isinstance(parsed, dict):
|
|
104
|
+
data = parsed
|
|
105
|
+
except Exception:
|
|
106
|
+
data = {}
|
|
107
|
+
reasons = data.get("full_disk_access_reasons")
|
|
108
|
+
if not isinstance(reasons, list):
|
|
109
|
+
reasons = []
|
|
110
|
+
if reason and reason not in reasons:
|
|
111
|
+
reasons.append(reason)
|
|
112
|
+
data["full_disk_access_status"] = "later"
|
|
113
|
+
data["full_disk_access_status_version"] = 1
|
|
114
|
+
data["full_disk_access_reasons"] = reasons
|
|
115
|
+
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
PY
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
approve_service() {
|
|
122
|
+
local svc="$1"
|
|
123
|
+
local client="$2"
|
|
124
|
+
local output
|
|
125
|
+
|
|
126
|
+
if output=$(sqlite3 "$TCC_DB" "
|
|
127
|
+
INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version)
|
|
128
|
+
VALUES ('$svc', '$client', 1, 2, 4, 1);
|
|
129
|
+
" 2>&1); then
|
|
130
|
+
return 0
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
log_line "WARN: failed TCC approval service=$svc client=$client: ${output:-sqlite3 failed}"
|
|
134
|
+
if is_fda_error "${output:-}"; then
|
|
135
|
+
FDA_REQUIRED=1
|
|
136
|
+
fi
|
|
137
|
+
return 1
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
approve_client() {
|
|
141
|
+
local label="$1"
|
|
142
|
+
local client="$2"
|
|
143
|
+
local marker="${3:-}"
|
|
144
|
+
local failed=0
|
|
145
|
+
|
|
146
|
+
log_line "Approving $label"
|
|
147
|
+
|
|
148
|
+
for svc in "${SERVICES[@]}"; do
|
|
149
|
+
if ! approve_service "$svc" "$client"; then
|
|
150
|
+
failed=$((failed + 1))
|
|
151
|
+
fi
|
|
152
|
+
done
|
|
153
|
+
|
|
154
|
+
if [ "$failed" -eq 0 ]; then
|
|
155
|
+
[ -n "$marker" ] && touch "$marker"
|
|
156
|
+
log_line "Done: $label — ${#SERVICES[@]} services approved"
|
|
157
|
+
return 0
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
log_line "FAILED: $label — $failed/${#SERVICES[@]} services failed"
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
30
163
|
|
|
31
164
|
# TCC services Claude Code needs
|
|
32
165
|
SERVICES=(
|
|
@@ -49,17 +182,11 @@ if [ -d "$VERSIONS_DIR" ]; then
|
|
|
49
182
|
# Skip if already approved
|
|
50
183
|
[ -f "$marker" ] && continue
|
|
51
184
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
VALUES ('$svc', '$bin_path', 1, 2, 4, 1);
|
|
58
|
-
" 2>/dev/null
|
|
59
|
-
done
|
|
60
|
-
|
|
61
|
-
touch "$marker"
|
|
62
|
-
echo "$(date '+%Y-%m-%d %H:%M:%S') Done: Claude $version — ${#SERVICES[@]} services approved" >> "$LOG"
|
|
185
|
+
if approve_client "Claude $version" "$bin_path" "$marker"; then
|
|
186
|
+
APPROVED_VERSIONS=$((APPROVED_VERSIONS + 1))
|
|
187
|
+
else
|
|
188
|
+
FAILED=1
|
|
189
|
+
fi
|
|
63
190
|
done
|
|
64
191
|
fi
|
|
65
192
|
|
|
@@ -69,11 +196,28 @@ if [ -n "$NEXO_CODE" ]; then
|
|
|
69
196
|
PYTHON_BIN="$(dirname "$NEXO_CODE")/.venv/bin/python"
|
|
70
197
|
if [ -e "$PYTHON_BIN" ]; then
|
|
71
198
|
PYTHON_REAL=$(readlink -f "$PYTHON_BIN" 2>/dev/null || echo "$PYTHON_BIN")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
done
|
|
199
|
+
if approve_client "NEXO Python" "$PYTHON_REAL"; then
|
|
200
|
+
PYTHON_APPROVED=1
|
|
201
|
+
else
|
|
202
|
+
FAILED=1
|
|
203
|
+
fi
|
|
78
204
|
fi
|
|
79
205
|
fi
|
|
206
|
+
|
|
207
|
+
if [ "$FAILED" -ne 0 ] && [ "$FDA_REQUIRED" -ne 0 ]; then
|
|
208
|
+
record_full_disk_access_required
|
|
209
|
+
log_line "Full Disk Access required: $FDA_REASON"
|
|
210
|
+
echo "TCC auto-approve: Full Disk Access required; Desktop will prompt the user"
|
|
211
|
+
exit 0
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
if [ "$FAILED" -ne 0 ]; then
|
|
215
|
+
echo "TCC auto-approve failed; see $LOG" >&2
|
|
216
|
+
exit 1
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
if [ "$APPROVED_VERSIONS" -eq 0 ] && [ "$PYTHON_APPROVED" -eq 0 ]; then
|
|
220
|
+
echo "TCC auto-approve: nothing pending"
|
|
221
|
+
else
|
|
222
|
+
echo "TCC auto-approve: approved $APPROVED_VERSIONS Claude version(s)"
|
|
223
|
+
fi
|