nexo-brain 2.6.5 → 2.6.6

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/bin/nexo-brain.js CHANGED
@@ -31,6 +31,7 @@ const LAUNCH_AGENTS = path.join(
31
31
  "Library",
32
32
  "LaunchAgents"
33
33
  );
34
+ const MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
34
35
 
35
36
  function isEphemeralInstall(nexoHome) {
36
37
  const homeDir = require("os").homedir();
@@ -114,6 +115,139 @@ function logMacPermissionsNotice(nexoHome, pythonPath = "") {
114
115
  log(" System Settings → Privacy & Security → Full Disk Access");
115
116
  }
116
117
 
118
+ function getRuntimePythonTargets(pythonPath = "") {
119
+ const candidates = [];
120
+ const venvPy = path.join(NEXO_HOME, ".venv", "bin", "python3");
121
+ if (fs.existsSync(venvPy)) candidates.push(venvPy);
122
+ if (pythonPath) candidates.push(pythonPath);
123
+ const discovered = run("which python3") || run("which python") || "";
124
+ if (discovered) candidates.push(discovered);
125
+ return [...new Set(candidates.filter(Boolean))];
126
+ }
127
+
128
+ function detectFullDiskAccessReasons(nexoHome) {
129
+ if (process.platform !== "darwin") return [];
130
+ const reasons = [];
131
+ if (isProtectedMacPath(nexoHome)) {
132
+ reasons.push(`NEXO_HOME is inside a protected macOS folder: ${nexoHome}`);
133
+ }
134
+
135
+ const logsDir = path.join(nexoHome, "logs");
136
+ if (fs.existsSync(logsDir)) {
137
+ const candidates = fs.readdirSync(logsDir).filter((name) => name.endsWith("-stderr.log"));
138
+ for (const name of candidates) {
139
+ try {
140
+ const text = fs.readFileSync(path.join(logsDir, name), "utf8");
141
+ if (text.includes("Operation not permitted")) {
142
+ reasons.push(`Recent background job stderr hit 'Operation not permitted' (${name})`);
143
+ break;
144
+ }
145
+ } catch {}
146
+ }
147
+ }
148
+ return reasons;
149
+ }
150
+
151
+ function probeFullDiskAccess(nexoHome) {
152
+ if (process.platform !== "darwin") {
153
+ return { checked: false, granted: null, probePath: "", message: "macOS-only" };
154
+ }
155
+
156
+ const candidates = [
157
+ path.join(require("os").homedir(), "Library", "Application Support", "com.apple.TCC", "TCC.db"),
158
+ path.join(require("os").homedir(), "Library", "Mail"),
159
+ path.join(require("os").homedir(), "Library", "Messages"),
160
+ path.join(require("os").homedir(), "Library", "Safari"),
161
+ path.join(require("os").homedir(), "Library", "Application Support", "AddressBook"),
162
+ ].filter((item) => fs.existsSync(item));
163
+
164
+ if (isProtectedMacPath(nexoHome)) candidates.push(nexoHome);
165
+ if (!candidates.length) {
166
+ return { checked: false, granted: null, probePath: "", message: "No probe path available." };
167
+ }
168
+
169
+ const seen = new Set();
170
+ for (const candidate of candidates) {
171
+ if (!candidate || seen.has(candidate)) continue;
172
+ seen.add(candidate);
173
+ const result = spawnSync("/bin/bash", [
174
+ "-lc",
175
+ 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi',
176
+ "_",
177
+ candidate,
178
+ ], { encoding: "utf8" });
179
+ if (result.status === 0) {
180
+ return { checked: true, granted: true, probePath: candidate, message: "" };
181
+ }
182
+ }
183
+ return { checked: true, granted: false, probePath: candidates[0], message: "Could not verify Full Disk Access yet." };
184
+ }
185
+
186
+ async function maybeConfigureFullDiskAccess(schedule, useDefaults, pythonPath = "") {
187
+ const current = String((schedule && schedule.full_disk_access_status) || "unset").toLowerCase();
188
+ schedule.full_disk_access_status_version = 1;
189
+ const reasons = detectFullDiskAccessReasons(NEXO_HOME);
190
+ schedule.full_disk_access_reasons = reasons;
191
+
192
+ if (process.platform !== "darwin" || !reasons.length) {
193
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
194
+ return schedule;
195
+ }
196
+
197
+ if (current === "granted") {
198
+ const probe = probeFullDiskAccess(NEXO_HOME);
199
+ if (probe.granted) {
200
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
201
+ return schedule;
202
+ }
203
+ schedule.full_disk_access_status = "later";
204
+ } else if (current === "declined") {
205
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
206
+ return schedule;
207
+ }
208
+
209
+ if (useDefaults || !process.stdin.isTTY || !process.stdout.isTTY) {
210
+ schedule.full_disk_access_status = current === "granted" ? "later" : current || "unset";
211
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
212
+ return schedule;
213
+ }
214
+
215
+ console.log("");
216
+ log("Optional macOS Full Disk Access guidance:");
217
+ log("macOS does not allow granting this automatically. NEXO can only open the correct System Settings screen and verify best effort.");
218
+ log("Reason(s) detected:");
219
+ reasons.forEach((item) => log(` - ${item}`));
220
+ log("If you proceed, add your terminal app and, if needed for background jobs, these binaries:");
221
+ log(" - /bin/bash");
222
+ getRuntimePythonTargets(pythonPath).forEach((item) => log(` - ${item}`));
223
+
224
+ const answer = (await ask(" Open Full Disk Access setup now? [y/N/later]: ")).trim().toLowerCase();
225
+ if (answer === "y" || answer === "yes") {
226
+ spawnSync("open", [MACOS_FDA_SETTINGS_URL], { stdio: "ignore" });
227
+ log("Opened System Settings → Privacy & Security → Full Disk Access.");
228
+ const followUp = (await ask(" Press Enter after granting it, or type later to skip for now: ")).trim().toLowerCase();
229
+ if (followUp === "later" || followUp === "l") {
230
+ schedule.full_disk_access_status = "later";
231
+ } else {
232
+ const probe = probeFullDiskAccess(NEXO_HOME);
233
+ if (probe.granted) {
234
+ schedule.full_disk_access_status = "granted";
235
+ log(`Full Disk Access verified via ${probe.probePath}.`);
236
+ } else {
237
+ schedule.full_disk_access_status = "later";
238
+ log("Could not verify Full Disk Access yet. NEXO will remind you later if background jobs still hit TCC.");
239
+ }
240
+ }
241
+ } else if (answer === "later" || answer === "l" || answer === "") {
242
+ schedule.full_disk_access_status = "later";
243
+ } else {
244
+ schedule.full_disk_access_status = "declined";
245
+ }
246
+
247
+ fs.writeFileSync(path.join(NEXO_HOME, "config", "schedule.json"), JSON.stringify(schedule, null, 2));
248
+ return schedule;
249
+ }
250
+
117
251
  // ══════════════════════════════════════════════════════════════════════════════
118
252
  // CORE PROCESS & HOOK DEFINITIONS
119
253
  // All core nightly/periodic processes and all 8 core hooks that make NEXO functional.
@@ -296,6 +430,9 @@ function getDefaultSchedule(timezone) {
296
430
  auto_update: true,
297
431
  power_policy: "unset",
298
432
  power_policy_version: 2,
433
+ full_disk_access_status: "unset",
434
+ full_disk_access_status_version: 1,
435
+ full_disk_access_reasons: [],
299
436
  processes: {
300
437
  "cognitive-decay": { hour: 3, minute: 0 },
301
438
  "postmortem": { hour: 23, minute: 30 },
@@ -844,6 +981,7 @@ async function main() {
844
981
  let migSchedule = loadOrCreateSchedule(NEXO_HOME);
845
982
  migSchedule = await maybeConfigurePowerPolicy(migSchedule, useDefaults);
846
983
  const migPython = findVenvPython(NEXO_HOME) || "python3";
984
+ migSchedule = await maybeConfigureFullDiskAccess(migSchedule, useDefaults, migPython);
847
985
  let migOptionals = {};
848
986
  try {
849
987
  const optFile = path.join(NEXO_HOME, "config", "optionals.json");
@@ -2116,6 +2254,7 @@ ${doScan ? `- Stack: ${Object.keys(profileData.code.languages || {}).slice(0, 5)
2116
2254
  log("Setting up automated processes...");
2117
2255
  let schedule = loadOrCreateSchedule(NEXO_HOME);
2118
2256
  schedule = await maybeConfigurePowerPolicy(schedule, useDefaults);
2257
+ schedule = await maybeConfigureFullDiskAccess(schedule, useDefaults, python);
2119
2258
  const enabledOptionals = { dashboard: doDashboard };
2120
2259
  if (isEphemeralInstall(NEXO_HOME)) {
2121
2260
  log("Ephemeral HOME/NEXO_HOME detected — skipping LaunchAgents installation.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.6.5",
3
+ "version": "2.6.6",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — local cognitive runtime for Claude Code. Persistent memory, overnight learning, recovery-aware crons, personal scripts, doctor diagnostics, startup preflight, and optional power helper.",
6
6
  "bin": {
@@ -1522,15 +1522,26 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1522
1522
  "migrations": [],
1523
1523
  "power_policy": None,
1524
1524
  "power_message": None,
1525
+ "full_disk_access_status": None,
1526
+ "full_disk_access_message": None,
1525
1527
  "error": None,
1526
1528
  }
1527
1529
 
1528
- from runtime_power import apply_power_policy, ensure_power_policy_choice, get_power_policy
1530
+ from runtime_power import (
1531
+ apply_power_policy,
1532
+ ensure_power_policy_choice,
1533
+ get_power_policy,
1534
+ ensure_full_disk_access_choice,
1535
+ get_full_disk_access_status,
1536
+ )
1529
1537
 
1530
1538
  choice = ensure_power_policy_choice(interactive=interactive, reason=entrypoint)
1531
1539
  power_result = apply_power_policy(choice.get("policy"))
1540
+ fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason=entrypoint)
1532
1541
  result["power_policy"] = choice.get("policy") or get_power_policy()
1533
1542
  result["power_message"] = power_result.get("message")
1543
+ result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
1544
+ result["full_disk_access_message"] = fda_choice.get("message")
1534
1545
  if power_result.get("ok"):
1535
1546
  result["actions"].append(f"power:{power_result.get('action')}")
1536
1547
 
@@ -1604,6 +1615,8 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
1604
1615
  result["entrypoint"] = entrypoint
1605
1616
  result["power_policy"] = choice.get("policy") or get_power_policy()
1606
1617
  result["power_message"] = power_result.get("message")
1618
+ result["full_disk_access_status"] = fda_choice.get("status") or get_full_disk_access_status()
1619
+ result["full_disk_access_message"] = fda_choice.get("message")
1607
1620
  if power_result.get("ok"):
1608
1621
  actions = result.setdefault("actions", [])
1609
1622
  actions.append(f"power:{power_result.get('action')}")
package/src/cli.py CHANGED
@@ -451,7 +451,13 @@ def _update(args):
451
451
  - Packaged/runtime-only install: delegate to plugins.update handle_update()
452
452
  """
453
453
  from auto_update import manual_sync_update, _resolve_sync_source
454
- from runtime_power import ensure_power_policy_choice, apply_power_policy, format_power_policy_label
454
+ from runtime_power import (
455
+ ensure_power_policy_choice,
456
+ apply_power_policy,
457
+ format_power_policy_label,
458
+ ensure_full_disk_access_choice,
459
+ format_full_disk_access_label,
460
+ )
455
461
 
456
462
  interactive = sys.stdin.isatty() and sys.stdout.isatty()
457
463
 
@@ -472,6 +478,7 @@ def _update(args):
472
478
  result = handle_update()
473
479
  choice = ensure_power_policy_choice(interactive=interactive, reason="update")
474
480
  power_result = apply_power_policy(choice.get("policy"))
481
+ fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
475
482
  if args.json:
476
483
  print(json.dumps({
477
484
  "mode": "packaged",
@@ -479,6 +486,9 @@ def _update(args):
479
486
  "power_policy": choice.get("policy"),
480
487
  "power_action": power_result.get("action"),
481
488
  "power_details": power_result.get("details"),
489
+ "full_disk_access_status": fda_choice.get("status"),
490
+ "full_disk_access_reasons": fda_choice.get("reasons"),
491
+ "full_disk_access_message": fda_choice.get("message"),
482
492
  }, indent=2, ensure_ascii=False))
483
493
  else:
484
494
  print(result)
@@ -486,16 +496,25 @@ def _update(args):
486
496
  print(f"Power policy: {format_power_policy_label(choice.get('policy'))}")
487
497
  if power_result.get("message"):
488
498
  print(f"Power helper: {power_result.get('message')}")
499
+ if fda_choice.get("prompted"):
500
+ print(f"Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
501
+ if fda_choice.get("message"):
502
+ print(f"Full Disk Access: {fda_choice.get('message')}")
489
503
  return 0 if "UPDATE SUCCESSFUL" in result or "Already up to date" in result else 1
490
504
 
491
505
  choice = ensure_power_policy_choice(interactive=interactive, reason="update")
492
506
  power_result = apply_power_policy(choice.get("policy"))
507
+ fda_choice = ensure_full_disk_access_choice(interactive=interactive, reason="update")
493
508
  result = manual_sync_update(interactive=interactive, allow_source_pull=True)
494
509
  result["power_policy"] = choice.get("policy")
495
510
  result["power_action"] = power_result.get("action")
496
511
  result["power_details"] = power_result.get("details")
512
+ result["full_disk_access_status"] = fda_choice.get("status")
513
+ result["full_disk_access_reasons"] = fda_choice.get("reasons")
497
514
  if power_result.get("message"):
498
515
  result["power_message"] = power_result.get("message")
516
+ if fda_choice.get("message"):
517
+ result["full_disk_access_message"] = fda_choice.get("message")
499
518
  if args.json:
500
519
  print(json.dumps(result, indent=2, ensure_ascii=False))
501
520
  else:
@@ -511,6 +530,10 @@ def _update(args):
511
530
  print(f" Power policy: {format_power_policy_label(choice.get('policy'))}")
512
531
  if power_result.get("message"):
513
532
  print(f" Power helper: {power_result.get('message')}")
533
+ if fda_choice.get("prompted"):
534
+ print(f" Full Disk Access: {format_full_disk_access_label(fda_choice.get('status'))}")
535
+ if fda_choice.get("message"):
536
+ print(f" Full Disk Access: {fda_choice.get('message')}")
514
537
  else:
515
538
  print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
516
539
  return 0 if result.get("ok") else 1
@@ -747,6 +747,11 @@ def check_launchagent_integrity(fix: bool = False) -> DoctorCheck:
747
747
  "and treat recent 'Operation not permitted' against Documents/Desktop/Downloads as a TCC/runtime path issue."
748
748
  ),
749
749
  )
750
+ if tcc_risk:
751
+ check.repair_plan.append(
752
+ "On macOS, grant Full Disk Access manually if protected folders are required; "
753
+ "NEXO can only open the System Settings pane and verify best effort"
754
+ )
750
755
 
751
756
  if fix:
752
757
  sync_ok, sync_evidence = _sync_launchagents_from_manifest()