nexo-brain 2.6.5 → 2.6.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.
@@ -19,6 +19,7 @@ import platform
19
19
  import plistlib
20
20
  import shutil
21
21
  import subprocess
22
+ import sys
22
23
  from pathlib import Path
23
24
 
24
25
 
@@ -37,11 +38,33 @@ VALID_POWER_POLICIES = {
37
38
  POWER_POLICY_DISABLED,
38
39
  POWER_POLICY_UNSET,
39
40
  }
41
+ FULL_DISK_ACCESS_STATUS_KEY = "full_disk_access_status"
42
+ FULL_DISK_ACCESS_STATUS_VERSION_KEY = "full_disk_access_status_version"
43
+ FULL_DISK_ACCESS_REASONS_KEY = "full_disk_access_reasons"
44
+ FULL_DISK_ACCESS_STATUS_VERSION = 1
45
+ FULL_DISK_ACCESS_UNSET = "unset"
46
+ FULL_DISK_ACCESS_GRANTED = "granted"
47
+ FULL_DISK_ACCESS_DECLINED = "declined"
48
+ FULL_DISK_ACCESS_LATER = "later"
49
+ VALID_FULL_DISK_ACCESS_STATUSES = {
50
+ FULL_DISK_ACCESS_UNSET,
51
+ FULL_DISK_ACCESS_GRANTED,
52
+ FULL_DISK_ACCESS_DECLINED,
53
+ FULL_DISK_ACCESS_LATER,
54
+ }
40
55
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
41
56
  LINUX_SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
42
57
  MACOS_CAFFEINATE_PATH = Path("/usr/bin/caffeinate")
43
58
  MACOS_CLOSED_LID_BEHAVIOR = "best_effort"
44
59
  LINUX_CLOSED_LID_BEHAVIOR = "host_policy"
60
+ MACOS_FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"
61
+ MACOS_FDA_PROBE_PATHS = (
62
+ Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db",
63
+ Path.home() / "Library" / "Mail",
64
+ Path.home() / "Library" / "Messages",
65
+ Path.home() / "Library" / "Safari",
66
+ Path.home() / "Library" / "Application Support" / "AddressBook",
67
+ )
45
68
 
46
69
 
47
70
  def _schedule_defaults() -> dict:
@@ -50,6 +73,9 @@ def _schedule_defaults() -> dict:
50
73
  "auto_update": True,
51
74
  POWER_POLICY_KEY: POWER_POLICY_UNSET,
52
75
  POWER_POLICY_VERSION_KEY: POWER_POLICY_VERSION,
76
+ FULL_DISK_ACCESS_STATUS_KEY: FULL_DISK_ACCESS_UNSET,
77
+ FULL_DISK_ACCESS_STATUS_VERSION_KEY: FULL_DISK_ACCESS_STATUS_VERSION,
78
+ FULL_DISK_ACCESS_REASONS_KEY: [],
53
79
  "processes": {},
54
80
  }
55
81
 
@@ -76,6 +102,13 @@ def save_schedule_config(schedule: dict) -> Path:
76
102
  payload.setdefault("processes", {})
77
103
  payload[POWER_POLICY_KEY] = normalize_power_policy(payload.get(POWER_POLICY_KEY))
78
104
  payload[POWER_POLICY_VERSION_KEY] = POWER_POLICY_VERSION
105
+ payload[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(
106
+ payload.get(FULL_DISK_ACCESS_STATUS_KEY)
107
+ )
108
+ payload[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
109
+ payload[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(
110
+ payload.get(FULL_DISK_ACCESS_REASONS_KEY)
111
+ )
79
112
  SCHEDULE_FILE.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
80
113
  return SCHEDULE_FILE
81
114
 
@@ -164,6 +197,286 @@ def describe_power_policy(policy: str | None = None, *, system: str | None = Non
164
197
  return base
165
198
 
166
199
 
200
+ def _protected_macos_roots(home: Path | None = None) -> tuple[Path, ...]:
201
+ home = home or Path.home()
202
+ return (
203
+ home / "Documents",
204
+ home / "Desktop",
205
+ home / "Downloads",
206
+ home / "Library" / "Mobile Documents",
207
+ )
208
+
209
+
210
+ def _is_protected_macos_path(candidate: str | os.PathLike[str] | Path | None) -> bool:
211
+ if not candidate:
212
+ return False
213
+ if platform.system() != "Darwin":
214
+ return False
215
+ resolved = Path(candidate).expanduser().resolve(strict=False)
216
+ return any(resolved == root or root in resolved.parents for root in _protected_macos_roots())
217
+
218
+
219
+ def normalize_full_disk_access_status(value: str | None) -> str:
220
+ candidate = str(value or "").strip().lower()
221
+ if candidate in {"enabled", "yes", "approved", "ok", "true", "1"}:
222
+ return FULL_DISK_ACCESS_GRANTED
223
+ if candidate in {"no", "disabled", "off", "false", "0"}:
224
+ return FULL_DISK_ACCESS_DECLINED
225
+ if candidate in VALID_FULL_DISK_ACCESS_STATUSES:
226
+ return candidate
227
+ return FULL_DISK_ACCESS_UNSET
228
+
229
+
230
+ def normalize_full_disk_access_reasons(value) -> list[str]:
231
+ if not value:
232
+ return []
233
+ if isinstance(value, str):
234
+ value = [value]
235
+ if not isinstance(value, (list, tuple, set)):
236
+ return []
237
+ reasons: list[str] = []
238
+ for item in value:
239
+ text = str(item or "").strip()
240
+ if text and text not in reasons:
241
+ reasons.append(text)
242
+ return reasons
243
+
244
+
245
+ def get_full_disk_access_status(schedule: dict | None = None) -> str:
246
+ schedule = schedule or load_schedule_config()
247
+ return normalize_full_disk_access_status(schedule.get(FULL_DISK_ACCESS_STATUS_KEY))
248
+
249
+
250
+ def format_full_disk_access_label(status: str | None = None, *, system: str | None = None) -> str:
251
+ status = normalize_full_disk_access_status(status or get_full_disk_access_status())
252
+ system = system or platform.system()
253
+ if system != "Darwin":
254
+ return "not_applicable"
255
+ if status == FULL_DISK_ACCESS_GRANTED:
256
+ return "granted"
257
+ if status == FULL_DISK_ACCESS_DECLINED:
258
+ return "declined"
259
+ if status == FULL_DISK_ACCESS_LATER:
260
+ return "later"
261
+ return "unset"
262
+
263
+
264
+ def _tail_has_permission_denial(log_file: Path) -> bool:
265
+ if not log_file.is_file():
266
+ return False
267
+ try:
268
+ with log_file.open("rb") as fh:
269
+ fh.seek(0, os.SEEK_END)
270
+ size = fh.tell()
271
+ fh.seek(max(size - 4096, 0))
272
+ tail = fh.read().decode("utf-8", errors="ignore")
273
+ return "Operation not permitted" in tail
274
+ except Exception:
275
+ return False
276
+
277
+
278
+ def detect_full_disk_access_reasons(*, system: str | None = None) -> list[str]:
279
+ system = system or platform.system()
280
+ if system != "Darwin":
281
+ return []
282
+
283
+ reasons: list[str] = []
284
+ if _is_protected_macos_path(NEXO_HOME):
285
+ reasons.append(
286
+ f"NEXO_HOME is inside a protected macOS folder: {NEXO_HOME}"
287
+ )
288
+
289
+ logs_dir = NEXO_HOME / "logs"
290
+ if logs_dir.is_dir():
291
+ for log_file in sorted(logs_dir.glob("*-stderr.log")):
292
+ if _tail_has_permission_denial(log_file):
293
+ reasons.append(
294
+ f"Recent background job stderr hit 'Operation not permitted' ({log_file.name})"
295
+ )
296
+ break
297
+ return reasons
298
+
299
+
300
+ def _runtime_python_candidates() -> list[str]:
301
+ candidates: list[str] = []
302
+ runtime_python = NEXO_HOME / ".venv" / "bin" / "python3"
303
+ if runtime_python.is_file():
304
+ candidates.append(str(runtime_python))
305
+ if sys.executable:
306
+ candidates.append(sys.executable)
307
+ python3_path = shutil.which("python3")
308
+ if python3_path:
309
+ candidates.append(python3_path)
310
+ seen: set[str] = set()
311
+ ordered: list[str] = []
312
+ for item in candidates:
313
+ if item and item not in seen:
314
+ seen.add(item)
315
+ ordered.append(item)
316
+ return ordered
317
+
318
+
319
+ def full_disk_access_targets() -> list[str]:
320
+ targets = ["/bin/bash", *(_runtime_python_candidates())]
321
+ seen: set[str] = set()
322
+ ordered: list[str] = []
323
+ for item in targets:
324
+ if item and item not in seen:
325
+ seen.add(item)
326
+ ordered.append(item)
327
+ return ordered
328
+
329
+
330
+ def open_full_disk_access_settings() -> dict:
331
+ if platform.system() != "Darwin":
332
+ return {"ok": False, "opened": False, "message": "Full Disk Access setup is macOS-only."}
333
+ try:
334
+ result = subprocess.run(
335
+ ["open", MACOS_FDA_SETTINGS_URL],
336
+ capture_output=True,
337
+ text=True,
338
+ timeout=5,
339
+ )
340
+ ok = result.returncode == 0
341
+ return {
342
+ "ok": ok,
343
+ "opened": ok,
344
+ "message": "" if ok else (result.stderr.strip() or result.stdout.strip()),
345
+ }
346
+ except Exception as e:
347
+ return {"ok": False, "opened": False, "message": str(e)}
348
+
349
+
350
+ def _probe_candidates() -> list[Path]:
351
+ candidates: list[Path] = []
352
+ for path_candidate in MACOS_FDA_PROBE_PATHS:
353
+ expanded = path_candidate.expanduser()
354
+ if expanded.exists():
355
+ candidates.append(expanded)
356
+ if _is_protected_macos_path(NEXO_HOME):
357
+ candidates.append(NEXO_HOME)
358
+ return candidates
359
+
360
+
361
+ def probe_full_disk_access() -> dict:
362
+ if platform.system() != "Darwin":
363
+ return {"checked": False, "granted": None, "probe_path": None, "message": "macOS-only"}
364
+
365
+ candidates = _probe_candidates()
366
+ if not candidates:
367
+ return {
368
+ "checked": False,
369
+ "granted": None,
370
+ "probe_path": None,
371
+ "message": "No local probe path available for verification.",
372
+ }
373
+
374
+ script = 'TARGET="$1"; if [ -d "$TARGET" ]; then ls "$TARGET" >/dev/null 2>&1; else head -c 1 "$TARGET" >/dev/null 2>&1; fi'
375
+ last_error = ""
376
+ for candidate in candidates:
377
+ try:
378
+ result = subprocess.run(
379
+ ["/bin/bash", "-lc", script, "_", str(candidate)],
380
+ capture_output=True,
381
+ text=True,
382
+ timeout=5,
383
+ )
384
+ except Exception as e:
385
+ last_error = str(e)
386
+ continue
387
+ if result.returncode == 0:
388
+ return {
389
+ "checked": True,
390
+ "granted": True,
391
+ "probe_path": str(candidate),
392
+ "message": "",
393
+ }
394
+ last_error = result.stderr.strip() or result.stdout.strip()
395
+ return {
396
+ "checked": True,
397
+ "granted": False,
398
+ "probe_path": str(candidates[0]),
399
+ "message": last_error,
400
+ }
401
+
402
+
403
+ def prompt_for_full_disk_access(
404
+ *,
405
+ reason: str = "install",
406
+ reasons: list[str] | None = None,
407
+ input_fn=input,
408
+ output_fn=print,
409
+ open_fn=open_full_disk_access_settings,
410
+ probe_fn=probe_full_disk_access,
411
+ ) -> dict:
412
+ reasons = normalize_full_disk_access_reasons(reasons)
413
+ output_fn(
414
+ "[NEXO] Some macOS background automations may need Full Disk Access. "
415
+ "macOS does not allow granting it automatically."
416
+ )
417
+ if reasons:
418
+ output_fn("[NEXO] Reason(s) detected:")
419
+ for item in reasons:
420
+ output_fn(f"[NEXO] - {item}")
421
+ output_fn("[NEXO] If you continue, NEXO will open the correct System Settings screen.")
422
+ output_fn("[NEXO] Add your terminal app and, if needed for background jobs, these binaries:")
423
+ for target in full_disk_access_targets():
424
+ output_fn(f"[NEXO] - {target}")
425
+
426
+ prompt = "[NEXO] Open Full Disk Access setup now? [y]es / [n]o / [l]ater: "
427
+ while True:
428
+ answer = str(input_fn(prompt)).strip().lower()
429
+ if answer in {"y", "yes"}:
430
+ open_result = open_fn()
431
+ if open_result.get("opened"):
432
+ output_fn("[NEXO] System Settings opened at Privacy & Security → Full Disk Access.")
433
+ elif open_result.get("message"):
434
+ output_fn(f"[NEXO] Could not open System Settings automatically: {open_result['message']}")
435
+ output_fn("[NEXO] Grant the permission, then press Enter to verify.")
436
+ follow_up = str(
437
+ input_fn("[NEXO] Press Enter after granting it, or type later to skip for now: ")
438
+ ).strip().lower()
439
+ if follow_up in {"later", "l"}:
440
+ return {
441
+ "status": FULL_DISK_ACCESS_LATER,
442
+ "settings_opened": bool(open_result.get("opened")),
443
+ "verified": False,
444
+ "message": "Full Disk Access setup deferred for later.",
445
+ }
446
+ probe = probe_fn()
447
+ if probe.get("granted") is True:
448
+ return {
449
+ "status": FULL_DISK_ACCESS_GRANTED,
450
+ "settings_opened": bool(open_result.get("opened")),
451
+ "verified": True,
452
+ "message": f"Full Disk Access verified via {probe.get('probe_path')}.",
453
+ }
454
+ return {
455
+ "status": FULL_DISK_ACCESS_LATER,
456
+ "settings_opened": bool(open_result.get("opened")),
457
+ "verified": False,
458
+ "message": (
459
+ "Could not verify Full Disk Access yet. NEXO will remind you later if "
460
+ "background jobs still hit TCC."
461
+ ),
462
+ }
463
+ if answer in {"n", "no"}:
464
+ return {
465
+ "status": FULL_DISK_ACCESS_DECLINED,
466
+ "settings_opened": False,
467
+ "verified": False,
468
+ "message": "Full Disk Access was declined.",
469
+ }
470
+ if answer in {"l", "later", ""}:
471
+ return {
472
+ "status": FULL_DISK_ACCESS_LATER,
473
+ "settings_opened": False,
474
+ "verified": False,
475
+ "message": "Full Disk Access setup deferred for later.",
476
+ }
477
+ output_fn("[NEXO] Reply with yes, no, or later.")
478
+
479
+
167
480
  def format_power_policy_label(policy: str | None = None, *, system: str | None = None) -> str:
168
481
  details = describe_power_policy(policy=policy, system=system)
169
482
  policy = details["policy"]
@@ -192,6 +505,16 @@ def set_power_policy(policy: str) -> dict:
192
505
  return schedule
193
506
 
194
507
 
508
+ def set_full_disk_access_status(status: str, *, reasons: list[str] | None = None) -> dict:
509
+ schedule = load_schedule_config()
510
+ schedule[FULL_DISK_ACCESS_STATUS_KEY] = normalize_full_disk_access_status(status)
511
+ schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
512
+ if reasons is not None:
513
+ schedule[FULL_DISK_ACCESS_REASONS_KEY] = normalize_full_disk_access_reasons(reasons)
514
+ save_schedule_config(schedule)
515
+ return schedule
516
+
517
+
195
518
  def prompt_for_power_policy(
196
519
  *,
197
520
  reason: str = "install",
@@ -248,6 +571,99 @@ def ensure_power_policy_choice(
248
571
  }
249
572
 
250
573
 
574
+ def ensure_full_disk_access_choice(
575
+ *,
576
+ interactive: bool,
577
+ reason: str = "update",
578
+ input_fn=input,
579
+ output_fn=print,
580
+ open_fn=open_full_disk_access_settings,
581
+ probe_fn=probe_full_disk_access,
582
+ ) -> dict:
583
+ schedule = load_schedule_config()
584
+ system = platform.system()
585
+ status = get_full_disk_access_status(schedule)
586
+ reasons = detect_full_disk_access_reasons(system=system)
587
+ prompted = False
588
+ verified = False
589
+ settings_opened = False
590
+ message = ""
591
+
592
+ if system != "Darwin":
593
+ return {
594
+ "status": status,
595
+ "prompted": False,
596
+ "verified": False,
597
+ "settings_opened": False,
598
+ "reasons": [],
599
+ "schedule_file": str(SCHEDULE_FILE),
600
+ "message": "",
601
+ "relevant": False,
602
+ }
603
+
604
+ schedule[FULL_DISK_ACCESS_REASONS_KEY] = reasons
605
+ schedule[FULL_DISK_ACCESS_STATUS_VERSION_KEY] = FULL_DISK_ACCESS_STATUS_VERSION
606
+
607
+ if not reasons:
608
+ save_schedule_config(schedule)
609
+ return {
610
+ "status": status,
611
+ "prompted": False,
612
+ "verified": False,
613
+ "settings_opened": False,
614
+ "reasons": [],
615
+ "schedule_file": str(SCHEDULE_FILE),
616
+ "message": "",
617
+ "relevant": False,
618
+ }
619
+
620
+ if status == FULL_DISK_ACCESS_GRANTED:
621
+ probe = probe_fn()
622
+ if probe.get("granted") is True:
623
+ verified = True
624
+ message = f"Full Disk Access verified via {probe.get('probe_path')}."
625
+ else:
626
+ status = FULL_DISK_ACCESS_LATER
627
+ message = (
628
+ "Full Disk Access was configured previously but could not be verified. "
629
+ "NEXO will remind you again on the next interactive update."
630
+ )
631
+
632
+ elif interactive and status in {FULL_DISK_ACCESS_UNSET, FULL_DISK_ACCESS_LATER}:
633
+ prompted = True
634
+ prompt_result = prompt_for_full_disk_access(
635
+ reason=reason,
636
+ reasons=reasons,
637
+ input_fn=input_fn,
638
+ output_fn=output_fn,
639
+ open_fn=open_fn,
640
+ probe_fn=probe_fn,
641
+ )
642
+ status = normalize_full_disk_access_status(prompt_result.get("status"))
643
+ verified = bool(prompt_result.get("verified"))
644
+ settings_opened = bool(prompt_result.get("settings_opened"))
645
+ message = str(prompt_result.get("message") or "")
646
+
647
+ elif status == FULL_DISK_ACCESS_DECLINED:
648
+ message = (
649
+ "Full Disk Access remains declined. Background jobs that touch protected "
650
+ "macOS folders may fail."
651
+ )
652
+
653
+ schedule[FULL_DISK_ACCESS_STATUS_KEY] = status
654
+ save_schedule_config(schedule)
655
+ return {
656
+ "status": status,
657
+ "prompted": prompted,
658
+ "verified": verified,
659
+ "settings_opened": settings_opened,
660
+ "reasons": reasons,
661
+ "schedule_file": str(SCHEDULE_FILE),
662
+ "message": message,
663
+ "relevant": True,
664
+ }
665
+
666
+
251
667
  def _prevent_sleep_script_path() -> Path:
252
668
  runtime_script = NEXO_HOME / "scripts" / "nexo-prevent-sleep.sh"
253
669
  if runtime_script.is_file():