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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- 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
|
+
}
|