pairling 0.0.1 → 0.1.0

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.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,613 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pairling power guardian.
4
+
5
+ Runs as a root LaunchDaemon in production. It samples macOS power, lid,
6
+ thermal, Tailscale, and pairlingd posture, then writes one atomic JSON snapshot
7
+ that pairlingd serves as /power-state. Optional enforcement can hold a
8
+ conservative `caffeinate -s -i -m` assertion and apply pmset posture, but the
9
+ default installed LaunchDaemon is observe-only.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ import signal
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ try:
26
+ from guardian_contract import build_guardian_info
27
+ except Exception:
28
+ build_guardian_info = None
29
+
30
+
31
+ SCHEMA_VERSION = 1
32
+ PORT = int(os.environ.get("COMPANION_DAEMON_PORT", os.environ.get("PAIRLING_RUNTIME_PORT", "7773")))
33
+ STATE_PATH = Path(os.environ.get(
34
+ "COMPANION_POWER_STATE_PATH",
35
+ "/var/run/pairling-power-state.json",
36
+ ))
37
+ INTERVAL_SECONDS = float(os.environ.get("COMPANION_POWER_INTERVAL_SECONDS", "20"))
38
+ ENFORCE_CAFFEINATE = os.environ.get("COMPANION_GUARDIAN_ENFORCE", "1") not in {"0", "false", "False"}
39
+ LOW_POWER_MODE_POLICY = os.environ.get("COMPANION_LOW_POWER_MODE_POLICY", "preserve").strip().lower()
40
+ CAFFEINATE_CMD = ["/usr/bin/caffeinate", "-s", "-i", "-m"]
41
+ PMSET_POSTURE = [
42
+ ("sleep", "0"),
43
+ ("displaysleep", "5"),
44
+ ("disksleep", "0"),
45
+ ("tcpkeepalive", "1"),
46
+ ("womp", "1"),
47
+ ("powernap", "1"),
48
+ ("standby", "0"),
49
+ ]
50
+ if LOW_POWER_MODE_POLICY in {"off", "disable", "disabled", "0"}:
51
+ PMSET_POSTURE.append(("lowpowermode", "0"))
52
+ elif LOW_POWER_MODE_POLICY in {"on", "enable", "enabled", "1"}:
53
+ PMSET_POSTURE.append(("lowpowermode", "1"))
54
+
55
+ caffeinate_proc: subprocess.Popen | None = None
56
+ stopping = False
57
+
58
+
59
+ def run_text(args: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
60
+ try:
61
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
62
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
63
+ except (OSError, subprocess.SubprocessError) as exc:
64
+ return 127, "", f"{type(exc).__name__}: {exc}"
65
+
66
+
67
+ def first_match(pattern: str, text: str) -> str | None:
68
+ match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
69
+ return match.group(1).strip() if match else None
70
+
71
+
72
+ def int_match(pattern: str, text: str) -> int | None:
73
+ value = first_match(pattern, text)
74
+ try:
75
+ return int(value) if value is not None else None
76
+ except ValueError:
77
+ return None
78
+
79
+
80
+ def bool_ioreg(name: str, text: str) -> bool | None:
81
+ value = first_match(rf'"{re.escape(name)}"\s*=\s*(Yes|No|true|false)', text)
82
+ if value is None:
83
+ return None
84
+ return value.lower() in {"yes", "true"}
85
+
86
+
87
+ def tailscale_ip() -> str | None:
88
+ code, out, _ = run_text(["tailscale", "ip", "-4"], timeout=2)
89
+ if code != 0:
90
+ return None
91
+ for line in out.splitlines():
92
+ ip = line.strip()
93
+ if ip.startswith("100."):
94
+ return ip
95
+ return None
96
+
97
+
98
+ def tailscale_status() -> str:
99
+ code, out, err = run_text(["tailscale", "status", "--peers=false"], timeout=3)
100
+ if code != 0:
101
+ return (err or "missing").strip()[:120]
102
+ first = next((line.strip() for line in out.splitlines() if line.strip()), "")
103
+ return first or "ok"
104
+
105
+
106
+ def pairling_connect_state() -> tuple[bool, str | None]:
107
+ """Probe the Pairling Connect embedded gateway (connectd) on loopback.
108
+
109
+ Returns (route_ready, tailnet_ip). connectd is a userspace tsnet node
110
+ with its own tailnet identity; when its gateway is healthy the iPhone
111
+ has a working tailnet path to pairlingd even while the standalone
112
+ Tailscale app is offline, so posture must treat it as a first-class
113
+ tailnet axis rather than reporting critical on the standalone CLI alone.
114
+ """
115
+ try:
116
+ import urllib.request
117
+
118
+ with urllib.request.urlopen("http://127.0.0.1:7774/status", timeout=2) as resp:
119
+ payload = json.loads(resp.read().decode("utf-8"))
120
+ except Exception:
121
+ return False, None
122
+ if not isinstance(payload, dict):
123
+ return False, None
124
+ ready = bool(
125
+ payload.get("auth_state") == "authenticated"
126
+ and payload.get("listener_running")
127
+ and payload.get("gateway_healthy")
128
+ and payload.get("tailnet_ip")
129
+ )
130
+ ip = str(payload.get("tailnet_ip") or "").strip() or None
131
+ return ready, ip
132
+
133
+
134
+ def default_interface() -> str | None:
135
+ code, out, _ = run_text(["/sbin/route", "-n", "get", "default"], timeout=2)
136
+ if code != 0:
137
+ return None
138
+ return first_match(r"^\s*interface:\s*(\S+)", out)
139
+
140
+
141
+ def lan_ips() -> list[str]:
142
+ code, out, _ = run_text(["/sbin/ifconfig"], timeout=3)
143
+ if code != 0:
144
+ return []
145
+ ips: list[str] = []
146
+ for match in re.finditer(r"\binet\s+(\d+\.\d+\.\d+\.\d+)\b", out):
147
+ ip = match.group(1)
148
+ if ip.startswith(("127.", "169.254.", "100.")):
149
+ continue
150
+ if ip not in ips:
151
+ ips.append(ip)
152
+ return ips
153
+
154
+
155
+ def tailscale_macos_plumbing() -> str:
156
+ """Diagnose the standalone Tailscale app's macOS plumbing.
157
+
158
+ `tailscale configure sysext status` (Standalone variant) reports whether
159
+ the network system extension is activated. That separates "sysext
160
+ disabled / app not installed correctly" from "running but logged out" —
161
+ the two need different recovery actions (`tailscale configure sysext
162
+ activate` + `tailscale configure mac-vpn install` vs. a browser login).
163
+ Returned string is surfaced in the power-state network block and in the
164
+ no-tailnet-route check message so the operator sees the recovery verb.
165
+ """
166
+ code, out, err = run_text(["tailscale", "configure", "sysext", "status"], timeout=3)
167
+ text = (out or err or "").strip()
168
+ first = next((line.strip() for line in text.splitlines() if line.strip()), "")
169
+ if not first:
170
+ return "unavailable" if code != 0 else "unknown"
171
+ return first[:120]
172
+
173
+
174
+ def docker_engine_alive() -> bool:
175
+ """Ping the Docker engine socket. The session-visibility plane (Postgres
176
+ reached via `docker exec`) dies silently when Docker Desktop is down —
177
+ surfacing on the phone as empty dashboards and "spawned but no
178
+ heartbeat". Naming it as a posture check turns that mystery into a
179
+ one-line diagnosis."""
180
+ import glob
181
+ import socket as socket_mod
182
+
183
+ candidates = ["/var/run/docker.sock"]
184
+ candidates.extend(sorted(glob.glob("/Users/*/.docker/run/docker.sock")))
185
+ for sock_path in candidates:
186
+ if not os.path.exists(sock_path):
187
+ continue
188
+ try:
189
+ with socket_mod.socket(socket_mod.AF_UNIX, socket_mod.SOCK_STREAM) as sock:
190
+ sock.settimeout(2)
191
+ sock.connect(sock_path)
192
+ sock.sendall(b"GET /_ping HTTP/1.0\r\nHost: docker\r\n\r\n")
193
+ response = sock.recv(256)
194
+ if b"200" in response.split(b"\r\n", 1)[0] or b"OK" in response:
195
+ return True
196
+ except OSError:
197
+ continue
198
+ return False
199
+
200
+
201
+ def listener_state(ts_ip: str | None) -> tuple[int | None, list[str], bool, bool]:
202
+ code, out, _ = run_text(["/usr/sbin/lsof", "-nP", f"-iTCP:{PORT}", "-sTCP:LISTEN"], timeout=3)
203
+ pid: int | None = None
204
+ entries: list[str] = []
205
+ if code == 0:
206
+ for line in out.splitlines()[1:]:
207
+ parts = line.split()
208
+ if len(parts) >= 2 and parts[1].isdigit() and pid is None:
209
+ pid = int(parts[1])
210
+ if " TCP " in line:
211
+ entry = line.split(" TCP ", 1)[1].replace(" (LISTEN)", "").strip()
212
+ if entry and entry not in entries:
213
+ entries.append(entry)
214
+ reachable_tailnet = tcp_accepts(ts_ip, PORT)
215
+ reachable_local = bool(entries) or tcp_accepts("127.0.0.1", PORT)
216
+ if not entries and ts_ip and reachable_tailnet:
217
+ entries.append(f"{ts_ip}:{PORT}")
218
+ return pid, entries, reachable_local, reachable_tailnet
219
+
220
+
221
+ def tcp_accepts(host: str | None, port: int, timeout: float = 0.5) -> bool:
222
+ if not host:
223
+ return False
224
+ try:
225
+ with socket.create_connection((host, port), timeout=timeout):
226
+ return True
227
+ except OSError:
228
+ return False
229
+
230
+
231
+ def parse_assertions(text: str) -> dict[str, bool | None]:
232
+ def flag(name: str) -> bool | None:
233
+ value = int_match(rf"^\s*{re.escape(name)}\s+([01])\s*$", text)
234
+ return bool(value) if value is not None else None
235
+
236
+ return {
237
+ "prevent_system_sleep": flag("PreventSystemSleep"),
238
+ "prevent_user_idle_system_sleep": flag("PreventUserIdleSystemSleep"),
239
+ "prevent_user_idle_display_sleep": flag("PreventUserIdleDisplaySleep"),
240
+ "raw_contains_caffeinate": "caffeinate" in text.lower(),
241
+ }
242
+
243
+
244
+ def enforce_pmset_posture() -> list[str]:
245
+ if not ENFORCE_CAFFEINATE:
246
+ return []
247
+ errors: list[str] = []
248
+ for key, value in PMSET_POSTURE:
249
+ code, _, err = run_text(["pmset", "-a", key, value], timeout=5)
250
+ if code != 0:
251
+ errors.append(f"pmset {key} {value}: {(err or 'failed').strip()[:160]}")
252
+ return errors
253
+
254
+
255
+ def pmset_drift(facts: dict[str, Any]) -> bool:
256
+ expected = {
257
+ "sleep_minutes": 0,
258
+ "display_sleep_minutes": 5,
259
+ "disk_sleep_minutes": 0,
260
+ }
261
+ for key, value in expected.items():
262
+ if facts.get(key) is not None and facts.get(key) != value:
263
+ return True
264
+ if LOW_POWER_MODE_POLICY in {"off", "disable", "disabled", "0"} and facts.get("low_power_mode") is True:
265
+ return True
266
+ if LOW_POWER_MODE_POLICY in {"on", "enable", "enabled", "1"} and facts.get("low_power_mode") is False:
267
+ return True
268
+ return False
269
+
270
+
271
+ def sample(pmset_enforce_errors: list[str] | None = None) -> dict[str, Any]:
272
+ now = time.time()
273
+ code_live, pmset_live, pmset_live_err = run_text(["pmset", "-g", "live"])
274
+ code_assert, assertions_text, assertions_err = run_text(["pmset", "-g", "assertions"])
275
+ code_batt, batt_text, batt_err = run_text(["pmset", "-g", "batt"])
276
+ code_therm, therm_text, therm_err = run_text(["pmset", "-g", "therm"])
277
+ code_ioreg, ioreg_text, ioreg_err = run_text([
278
+ "ioreg", "-r", "-k", "AppleClamshellState", "-d", "1",
279
+ ])
280
+
281
+ ac_power = "AC Power" in batt_text
282
+ battery_percent = int_match(r"(\d+)%;", batt_text)
283
+ low_power = int_match(r"^\s*lowpowermode\s+(\d+)\b", pmset_live)
284
+ sleep_value = int_match(r"^\s*sleep\s+(\d+)\b", pmset_live)
285
+ displaysleep_value = int_match(r"^\s*displaysleep\s+(\d+)\b", pmset_live)
286
+ disksleep_value = int_match(r"^\s*disksleep\s+(\d+)\b", pmset_live)
287
+ lid_closed = bool_ioreg("AppleClamshellState", ioreg_text)
288
+ clamshell_causes_sleep = bool_ioreg("AppleClamshellCausesSleep", ioreg_text)
289
+ cpu_speed_limit = int_match(r"CPU_Speed_Limit\s*=\s*(\d+)", therm_text)
290
+ scheduler_limit = int_match(r"CPU_Scheduler_Limit\s*=\s*(\d+)", therm_text)
291
+ available_cpus = int_match(r"CPU_Available_CPUs\s*=\s*(\d+)", therm_text)
292
+ assertions = parse_assertions(assertions_text)
293
+ ts_ip = tailscale_ip()
294
+ ts_status = tailscale_status()
295
+ ts_sysext = tailscale_macos_plumbing()
296
+ pc_ready, pc_ip = pairling_connect_state()
297
+ docker_alive = docker_engine_alive()
298
+ notify_pid, listen_entries, daemon_reachable_local, daemon_reachable_tailnet = listener_state(ts_ip)
299
+ enforce_errors = pmset_enforce_errors or []
300
+
301
+ facts: dict[str, Any] = {
302
+ "ac_power": ac_power,
303
+ "battery_percent": battery_percent,
304
+ "low_power_mode": bool(low_power) if low_power is not None else None,
305
+ "sleep_minutes": sleep_value,
306
+ "display_sleep_minutes": displaysleep_value,
307
+ "disk_sleep_minutes": disksleep_value,
308
+ "lid_closed": lid_closed,
309
+ "clamshell_causes_sleep": clamshell_causes_sleep,
310
+ "thermal_cpu_speed_limit": cpu_speed_limit,
311
+ "thermal_cpu_scheduler_limit": scheduler_limit,
312
+ "thermal_available_cpus": available_cpus,
313
+ "tailscale_ip": ts_ip,
314
+ "tailscale_status": ts_status,
315
+ "tailscale_sysext_status": ts_sysext,
316
+ "pairling_connect_ready": pc_ready,
317
+ "pairling_connect_ip": pc_ip,
318
+ "docker_engine_alive": docker_alive,
319
+ "daemon_reachable": daemon_reachable_tailnet,
320
+ "caffeinate_pid": caffeinate_proc.pid if caffeinate_proc and caffeinate_proc.poll() is None else None,
321
+ **assertions,
322
+ }
323
+
324
+ # Power source is informational only: being on battery must never flip the
325
+ # coordinator posture (the user can run the Mac on battery deliberately).
326
+ # Real availability loss is still caught by tailscale_ip/daemon_reachable.
327
+ # The pmset battery profile and the guardian's own caffeinate policy both
328
+ # legitimately differ on battery, so those checks are also informational
329
+ # while unplugged — they only enforce on AC.
330
+ checks = [
331
+ check("ac_power", True, "AC power is connected." if ac_power else "Running on battery power.", "", "ready"),
332
+ check("lid_open", lid_closed is False, "Lid is open.", "Lid is closed; macOS clamshell sleep may cut networking.", "unsafe"),
333
+ low_power_mode_check(low_power),
334
+ check(
335
+ "system_sleep",
336
+ (sleep_value == 0) or not ac_power,
337
+ "System sleep is disabled." if sleep_value == 0 else "System sleep follows the battery profile while unplugged (informational).",
338
+ f"System sleep is set to {sleep_value if sleep_value is not None else 'unknown'}.",
339
+ "unsafe",
340
+ ),
341
+ check(
342
+ "disk_sleep",
343
+ (disksleep_value == 0) or not ac_power,
344
+ "Disk sleep is disabled." if disksleep_value == 0 else "Disk sleep follows the battery profile while unplugged (informational).",
345
+ f"Disk sleep is set to {disksleep_value if disksleep_value is not None else 'unknown'}.",
346
+ "warning",
347
+ ),
348
+ check(
349
+ "prevent_system_sleep",
350
+ (assertions.get("prevent_system_sleep") is True) or not ac_power,
351
+ "A PreventSystemSleep assertion is active." if assertions.get("prevent_system_sleep") is True else "Sleep assertions are not enforced on battery power (informational).",
352
+ "No PreventSystemSleep assertion is active.",
353
+ "unsafe",
354
+ ),
355
+ check(
356
+ "tailscale_ip",
357
+ bool(ts_ip) or pc_ready,
358
+ "Tailscale tailnet IP is present." if ts_ip else "Pairling Connect tailnet route is ready.",
359
+ f"No tailnet route: standalone Tailscale is offline (sysext: {ts_sysext}) and Pairling Connect is not ready.",
360
+ "unsafe",
361
+ ),
362
+ check(
363
+ "daemon_reachable",
364
+ daemon_reachable_tailnet or pc_ready,
365
+ "pairlingd accepts TCP on the tailnet IP." if daemon_reachable_tailnet else "pairlingd is reachable via the Pairling Connect gateway.",
366
+ "pairlingd is not reachable on any tailnet route.",
367
+ "unsafe",
368
+ ),
369
+ check("thermal", thermal_ok(cpu_speed_limit, scheduler_limit), "Thermal limits are normal.", "macOS reports thermal throttling.", "warning"),
370
+ check(
371
+ "docker_engine",
372
+ docker_alive,
373
+ "Docker engine is running (session visibility healthy).",
374
+ "Docker engine is not running — session lists and heartbeats are degraded. Open Docker Desktop (and enable 'Start when you sign in').",
375
+ "warning",
376
+ ),
377
+ ]
378
+ for idx, err in enumerate(enforce_errors, start=1):
379
+ checks.append(check(f"pmset_enforce_{idx}", False, "pmset posture enforced.", err, "warning"))
380
+
381
+ unsafe = [c for c in checks if not c["ok"] and c["severity"] == "unsafe"]
382
+ warnings = [c for c in checks if not c["ok"] and c["severity"] == "warning"]
383
+ if unsafe:
384
+ posture = "unsafe"
385
+ severity = "critical"
386
+ ok = False
387
+ summary = unsafe[0]["message"]
388
+ elif warnings:
389
+ posture = "warning"
390
+ severity = "warning"
391
+ ok = True
392
+ summary = warnings[0]["message"]
393
+ else:
394
+ posture = "ready"
395
+ severity = "ok"
396
+ ok = True
397
+ summary = "Mac is in always-on coordinator posture."
398
+
399
+ return {
400
+ "schema_version": SCHEMA_VERSION,
401
+ "ok": ok,
402
+ "generated_at": now,
403
+ "guardian": build_guardian_info(__file__) if build_guardian_info else {
404
+ "runtime_version": "legacy",
405
+ "contract_version": "pairling-runtime-v1",
406
+ "source_revision": "unknown",
407
+ "launchd_label": "dev.pairling.power-guardian",
408
+ "verified": False,
409
+ "manifest_error": "guardian manifest helpers unavailable",
410
+ },
411
+ "host": {
412
+ "name": os.environ.get("COMPANION_COORDINATOR_HOST", "pairling-mac"),
413
+ "role": "primary_coordinator",
414
+ },
415
+ "posture": {
416
+ "status": posture,
417
+ "severity": severity,
418
+ "summary": summary,
419
+ },
420
+ "power": {
421
+ "ac_power": ac_power,
422
+ "battery_percent": battery_percent,
423
+ "low_power_mode": bool(low_power) if low_power is not None else None,
424
+ "system_sleep_disabled": sleep_value == 0 if sleep_value is not None else None,
425
+ "display_sleep_minutes": displaysleep_value,
426
+ "disk_sleep_disabled": disksleep_value == 0 if disksleep_value is not None else None,
427
+ "caffeinate_pid": facts["caffeinate_pid"],
428
+ "prevent_system_sleep": assertions.get("prevent_system_sleep"),
429
+ "prevent_idle_system_sleep": assertions.get("prevent_user_idle_system_sleep"),
430
+ "prevent_display_sleep": assertions.get("prevent_user_idle_display_sleep"),
431
+ },
432
+ "lid": {
433
+ "closed": lid_closed,
434
+ "apple_clamshell_causes_sleep": clamshell_causes_sleep,
435
+ "supported_posture": lid_closed is False,
436
+ },
437
+ "thermal": {
438
+ "state": "nominal" if thermal_ok(cpu_speed_limit, scheduler_limit) else "warning",
439
+ "cpu_speed_limit": cpu_speed_limit,
440
+ "cpu_scheduler_limit": scheduler_limit,
441
+ },
442
+ "network": {
443
+ "tailscale_installed": ts_ip is not None,
444
+ "tailscale_variant": "standalone",
445
+ "tailscale_ip": ts_ip,
446
+ "tailscale_status": "ok" if ts_ip else ts_status,
447
+ "tailscale_sysext_status": ts_sysext,
448
+ "pairling_connect": {
449
+ "route_ready": pc_ready,
450
+ "tailnet_ip": pc_ip,
451
+ },
452
+ "default_interface": default_interface(),
453
+ "lan_ips": lan_ips(),
454
+ },
455
+ "daemon": {
456
+ "pairlingd_pid": notify_pid,
457
+ "listen": listen_entries,
458
+ "reachable_local": daemon_reachable_local,
459
+ "reachable_tailnet": daemon_reachable_tailnet,
460
+ },
461
+ "warnings": [c["message"] for c in checks if not c["ok"]],
462
+ "checks": {c["id"]: "ok" if c["ok"] else c["message"] for c in checks},
463
+ "ts": now,
464
+ "summary": summary,
465
+ "severity": severity,
466
+ "status": posture,
467
+ "facts": facts,
468
+ "actions": recommended_actions(checks),
469
+ "command_errors": {
470
+ "pmset_live": pmset_live_err if code_live else None,
471
+ "pmset_assertions": assertions_err if code_assert else None,
472
+ "pmset_batt": batt_err if code_batt else None,
473
+ "pmset_therm": therm_err if code_therm else None,
474
+ "ioreg": ioreg_err if code_ioreg else None,
475
+ "pmset_enforce": enforce_errors or None,
476
+ },
477
+ "caffeinate": {
478
+ "enforced": ENFORCE_CAFFEINATE,
479
+ "command": " ".join(CAFFEINATE_CMD),
480
+ "pid": facts["caffeinate_pid"],
481
+ },
482
+ "policy": {
483
+ "low_power_mode": LOW_POWER_MODE_POLICY,
484
+ },
485
+ }
486
+
487
+
488
+ def check(identifier: str, ok: bool, good: str, bad: str, severity: str) -> dict[str, Any]:
489
+ return {
490
+ "id": identifier,
491
+ "ok": bool(ok),
492
+ "severity": "ready" if ok else severity,
493
+ "message": good if ok else bad,
494
+ }
495
+
496
+
497
+ def low_power_mode_check(value: int | None) -> dict[str, Any]:
498
+ enabled = bool(value) if value is not None else None
499
+ if LOW_POWER_MODE_POLICY in {"off", "disable", "disabled", "0"}:
500
+ return check("low_power_mode", enabled is False, "Low Power Mode is off.", "Low Power Mode is enabled.", "warning")
501
+ if LOW_POWER_MODE_POLICY in {"on", "enable", "enabled", "1"}:
502
+ return check("low_power_mode", enabled is True, "Low Power Mode is on.", "Low Power Mode is off.", "warning")
503
+ if enabled is True:
504
+ return check("low_power_mode", True, "Low Power Mode is on and preserved for thermal control.", "", "ready")
505
+ if enabled is False:
506
+ return check("low_power_mode", True, "Low Power Mode is off and preserved.", "", "ready")
507
+ return check("low_power_mode", True, "Low Power Mode state is unavailable.", "", "ready")
508
+
509
+
510
+ def thermal_ok(cpu_speed_limit: int | None, scheduler_limit: int | None) -> bool:
511
+ if cpu_speed_limit is not None and cpu_speed_limit < 80:
512
+ return False
513
+ if scheduler_limit is not None and scheduler_limit < 80:
514
+ return False
515
+ return True
516
+
517
+
518
+ def recommended_actions(checks: list[dict[str, Any]]) -> list[str]:
519
+ action_map = {
520
+ "lid_open": "Keep the lid open, or use a proven clamshell setup with external display and power.",
521
+ "system_sleep": "Set system sleep to Never with SleepToggle or pmset.",
522
+ "prevent_system_sleep": "Start the guardian LaunchDaemon so caffeinate can hold a system-sleep assertion.",
523
+ "tailscale_ip": "Start Tailscale and verify the standalone app is connected.",
524
+ "daemon_reachable": "Restart dev.pairling.companiond and verify it binds to the selected route.",
525
+ "thermal": "Reduce thermal load or disconnect hot external-display setups before long missions.",
526
+ }
527
+ actions: list[str] = []
528
+ for item in checks:
529
+ if not item.get("ok"):
530
+ action = action_map.get(str(item.get("id")))
531
+ if action and action not in actions:
532
+ actions.append(action)
533
+ return actions
534
+
535
+
536
+ def manage_caffeinate(ac_power: bool) -> None:
537
+ global caffeinate_proc
538
+ alive = caffeinate_proc is not None and caffeinate_proc.poll() is None
539
+ if not ENFORCE_CAFFEINATE or not ac_power:
540
+ if alive:
541
+ caffeinate_proc.terminate()
542
+ try:
543
+ caffeinate_proc.wait(timeout=3)
544
+ except subprocess.TimeoutExpired:
545
+ caffeinate_proc.kill()
546
+ caffeinate_proc = None
547
+ return
548
+ if alive:
549
+ return
550
+ caffeinate_proc = subprocess.Popen(
551
+ CAFFEINATE_CMD,
552
+ stdout=subprocess.DEVNULL,
553
+ stderr=subprocess.DEVNULL,
554
+ )
555
+
556
+
557
+ def write_state(payload: dict[str, Any]) -> None:
558
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
559
+ fd, tmp_name = tempfile.mkstemp(prefix=STATE_PATH.name + ".", dir=str(STATE_PATH.parent))
560
+ try:
561
+ with os.fdopen(fd, "w") as fh:
562
+ os.fchmod(fh.fileno(), 0o644)
563
+ json.dump(payload, fh, indent=2, sort_keys=True)
564
+ fh.write("\n")
565
+ fh.flush()
566
+ os.fsync(fh.fileno())
567
+ os.replace(tmp_name, STATE_PATH)
568
+ os.chmod(STATE_PATH, 0o644)
569
+ finally:
570
+ try:
571
+ os.unlink(tmp_name)
572
+ except FileNotFoundError:
573
+ pass
574
+
575
+
576
+ def handle_signal(signum: int, frame: Any) -> None:
577
+ global stopping
578
+ stopping = True
579
+
580
+
581
+ def main() -> int:
582
+ signal.signal(signal.SIGTERM, handle_signal)
583
+ signal.signal(signal.SIGINT, handle_signal)
584
+ next_pmset_enforce = 0.0
585
+ pmset_enforce_errors: list[str] = []
586
+ while not stopping:
587
+ now = time.time()
588
+ if ENFORCE_CAFFEINATE and now >= next_pmset_enforce:
589
+ pmset_enforce_errors = enforce_pmset_posture()
590
+ next_pmset_enforce = now + 300
591
+
592
+ payload = sample(pmset_enforce_errors)
593
+ if ENFORCE_CAFFEINATE and pmset_drift(payload.get("facts", {})):
594
+ pmset_enforce_errors = enforce_pmset_posture()
595
+ next_pmset_enforce = time.time() + 300
596
+ payload = sample(pmset_enforce_errors)
597
+ manage_caffeinate(bool(payload.get("facts", {}).get("ac_power")))
598
+ payload = sample(pmset_enforce_errors)
599
+ write_state(payload)
600
+ deadline = time.time() + INTERVAL_SECONDS
601
+ while not stopping and time.time() < deadline:
602
+ time.sleep(0.5)
603
+ manage_caffeinate(False)
604
+ return 0
605
+
606
+
607
+ if __name__ == "__main__":
608
+ try:
609
+ raise SystemExit(main())
610
+ except Exception as exc:
611
+ print(f"pairling-power-guardian fatal: {type(exc).__name__}: {exc}", file=sys.stderr)
612
+ manage_caffeinate(False)
613
+ raise
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env python3
2
+ """Manifest metadata helpers for the privileged power guardian."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ SCHEMA_VERSION = 1
13
+ CONTRACT_VERSION = "pairling-runtime-v1"
14
+ GUARDIAN_LABEL = "dev.pairling.power-guardian"
15
+
16
+
17
+ def sha256_file(path: Path) -> str:
18
+ digest = hashlib.sha256()
19
+ with path.open("rb") as fh:
20
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
21
+ digest.update(chunk)
22
+ return digest.hexdigest()
23
+
24
+
25
+ def build_guardian_info(script_path: str | Path) -> dict[str, Any]:
26
+ script = Path(script_path).resolve()
27
+ root = script.parent.parent if script.parent.name == "guardian" else None
28
+ manifest_path = root / "manifest.json" if root else None
29
+ runtime_version = os.environ.get("COMPANION_RUNTIME_VERSION", "legacy")
30
+ source_revision = os.environ.get("COMPANION_SOURCE_REVISION", "unknown")
31
+ source_hash = None
32
+ verified = False
33
+ manifest_error = "manifest not found for script path"
34
+
35
+ try:
36
+ source_hash = sha256_file(script)
37
+ except Exception as exc:
38
+ manifest_error = f"{type(exc).__name__}: {exc}"
39
+
40
+ if manifest_path and manifest_path.is_file():
41
+ try:
42
+ manifest = json.loads(manifest_path.read_text())
43
+ runtime_version = str(manifest.get("runtime_version") or runtime_version)
44
+ source_revision = str(manifest.get("source_revision") or source_revision)
45
+ expected_hash = None
46
+ for item in manifest.get("files") or []:
47
+ if isinstance(item, dict) and item.get("path") == "guardian/companion-power-guardian.py":
48
+ expected_hash = item.get("sha256")
49
+ break
50
+ if expected_hash and source_hash:
51
+ verified = expected_hash == source_hash
52
+ manifest_error = None if verified else "hash mismatch for guardian/companion-power-guardian.py"
53
+ else:
54
+ manifest_error = "manifest missing hash for guardian/companion-power-guardian.py"
55
+ except Exception as exc:
56
+ manifest_error = f"{type(exc).__name__}: {exc}"
57
+
58
+ return {
59
+ "runtime_version": runtime_version,
60
+ "contract_version": CONTRACT_VERSION,
61
+ "source_revision": source_revision,
62
+ "launchd_label": GUARDIAN_LABEL,
63
+ "verified": verified,
64
+ "source_hash": source_hash,
65
+ "manifest_path": str(manifest_path) if manifest_path else None,
66
+ "manifest_error": manifest_error,
67
+ }