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,660 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export PYTHONDONTWRITEBYTECODE=1
5
+ if [[ -z "${PYTHONPYCACHEPREFIX:-}" ]]; then
6
+ PYTHONPYCACHEPREFIX="${TMPDIR:-/tmp}/pairling-pycache-$(id -u)"
7
+ mkdir -p "$PYTHONPYCACHEPREFIX" 2>/dev/null || true
8
+ export PYTHONPYCACHEPREFIX
9
+ fi
10
+
11
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
+ JSON_MODE="false"
13
+ FIRST_RUN_MODE="false"
14
+ while [[ $# -gt 0 ]]; do
15
+ case "$1" in
16
+ --json)
17
+ JSON_MODE="true"
18
+ ;;
19
+ --first-run)
20
+ FIRST_RUN_MODE="true"
21
+ ;;
22
+ --help|-h)
23
+ cat <<EOF
24
+ usage: pairling doctor [--json] [--first-run]
25
+
26
+ Validates the Pairling Mac runtime. --first-run adds a machine-readable
27
+ readiness contract for onboarding and pairing rehearsals.
28
+ EOF
29
+ exit 0
30
+ ;;
31
+ *)
32
+ echo "usage: pairling doctor [--json] [--first-run]" >&2
33
+ exit 2
34
+ ;;
35
+ esac
36
+ shift
37
+ done
38
+
39
+ python3 - "$REPO_ROOT" "$JSON_MODE" "$FIRST_RUN_MODE" <<'PY'
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ import os
45
+ import plistlib
46
+ import re
47
+ import socket
48
+ import sqlite3
49
+ import subprocess
50
+ import sys
51
+ import time
52
+ import urllib.error
53
+ import urllib.request
54
+ from pathlib import Path
55
+
56
+ repo_root = Path(sys.argv[1])
57
+ json_mode = sys.argv[2] == "true"
58
+ first_run_mode = sys.argv[3] == "true"
59
+ home = Path.home()
60
+
61
+ PAIRLING_PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
62
+ PAIRLING_LABEL = "dev.pairling.companiond"
63
+ PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
64
+ PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
65
+ LEGACY_LABEL = "com.mghome.notify-webhook"
66
+ APP_SUPPORT = Path(os.environ.get("PAIRLING_APP_SUPPORT_ROOT", os.environ.get("COMPANION_APP_SUPPORT_ROOT", str(home / "Library" / "Application Support" / "Pairling"))))
67
+ LOGS_ROOT = Path(os.environ.get("PAIRLING_LOGS_ROOT", os.environ.get("COMPANION_LOGS_ROOT", str(home / "Library" / "Logs" / "Pairling"))))
68
+ CURRENT = APP_SUPPORT / "runtime" / "current"
69
+ MANIFEST_PATH = CURRENT / "manifest.json"
70
+ DEVICES_DB = APP_SUPPORT / "devices.sqlite"
71
+ MCP_CREDENTIAL = Path(os.environ.get("PAIRLING_MCP_CREDENTIAL", str(APP_SUPPORT / "mcp-bridge.json")))
72
+ MCP_ADAPTER = CURRENT / "mcp" / "phone_tools.py"
73
+ MCP_SHIM = home / ".claude" / "mcp-servers" / "phone-tools.py"
74
+ USER_PAIRLING = home / ".local" / "bin" / "pairling"
75
+ PAIR_ROOT = APP_SUPPORT / "pair"
76
+ USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_LABEL}.plist"
77
+ CONNECTD_USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_CONNECTD_LABEL}.plist"
78
+ LEGACY_USER_PLIST = home / "Library" / "LaunchAgents" / f"{LEGACY_LABEL}.plist"
79
+ SYSTEM_PLIST = Path("/Library/LaunchDaemons") / f"{PAIRLING_GUARDIAN_LABEL}.plist"
80
+ LEGACY_SYSTEM_PLIST = Path("/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist")
81
+
82
+ sys.path.insert(0, str(repo_root / "mac" / "companiond"))
83
+ from pairling_connectd_status import fetch_connectd_status, redacted_connectd_summary
84
+
85
+ checks = []
86
+
87
+
88
+ def add(identifier, ok, severity, summary, evidence=None):
89
+ checks.append({
90
+ "id": identifier,
91
+ "status": "ok" if ok else "fail",
92
+ "severity": severity,
93
+ "summary": summary,
94
+ "evidence": evidence,
95
+ })
96
+
97
+
98
+ def run(args, timeout=5):
99
+ try:
100
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
101
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
102
+ except Exception as exc:
103
+ return 127, "", f"{type(exc).__name__}: {exc}"
104
+
105
+
106
+ def load_plist(path):
107
+ with path.open("rb") as fh:
108
+ return plistlib.load(fh)
109
+
110
+
111
+ def sha256_file(path):
112
+ digest = hashlib.sha256()
113
+ with path.open("rb") as fh:
114
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
115
+ digest.update(chunk)
116
+ return digest.hexdigest()
117
+
118
+
119
+ def writable_dir(path: Path) -> tuple[bool, str]:
120
+ try:
121
+ path.mkdir(parents=True, exist_ok=True)
122
+ probe = path / ".pairling-doctor-write-test"
123
+ probe.write_text("ok")
124
+ probe.unlink()
125
+ return True, str(path)
126
+ except Exception as exc:
127
+ return False, f"{type(exc).__name__}: {exc}"
128
+
129
+
130
+ def port_listeners(port: int) -> list[str]:
131
+ code, out, err = run(["/usr/sbin/lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], timeout=3)
132
+ if code != 0:
133
+ return []
134
+ return [line.strip() for line in out.splitlines()[1:] if line.strip()]
135
+
136
+
137
+ def tcp_accepts(host: str, port: int, timeout: float = 0.5) -> bool:
138
+ try:
139
+ with socket.create_connection((host, port), timeout=timeout):
140
+ return True
141
+ except OSError:
142
+ return False
143
+
144
+
145
+ def detected_tailnet_ip() -> str | None:
146
+ override = os.environ.get("PAIRLING_TEST_TAILSCALE_IP")
147
+ if override is not None:
148
+ value = override.strip()
149
+ return value if value.startswith("100.") else None
150
+ code, out, _ = run(["tailscale", "ip", "-4"], timeout=3)
151
+ if code != 0:
152
+ return None
153
+ for line in out.splitlines():
154
+ ip = line.strip()
155
+ if ip.startswith("100."):
156
+ return ip
157
+ return None
158
+
159
+
160
+ def permission_readiness() -> dict:
161
+ return {
162
+ "ios_local_network": {
163
+ "required_for": ["bonjour_pairing", "lan_route_validation"],
164
+ "status": "requires_user_prompt",
165
+ },
166
+ "ios_camera": {
167
+ "required_for": ["qr_scan"],
168
+ "status": "not_requested",
169
+ },
170
+ "mac_accessibility": {
171
+ "required_for": ["terminal_ui_synthesis"],
172
+ "status": "not_required_until_terminal_control",
173
+ },
174
+ "mac_automation": {
175
+ "required_for": ["terminal_app_control"],
176
+ "status": "not_required_by_default",
177
+ },
178
+ "privacy_database": "not_modified",
179
+ }
180
+
181
+
182
+ def active_pair_records(pair_root: Path) -> list[dict]:
183
+ now = time.time()
184
+ records: list[dict] = []
185
+ if not pair_root.exists():
186
+ return records
187
+ for path in pair_root.glob("*.json"):
188
+ try:
189
+ payload = json.loads(path.read_text())
190
+ expires_at = float(payload.get("expires_at") or 0)
191
+ except Exception:
192
+ continue
193
+ if expires_at <= now:
194
+ continue
195
+ records.append({
196
+ "pair_id": payload.get("pair_id") or path.stem,
197
+ "runtime_port": payload.get("runtime_port"),
198
+ "expires_at": expires_at,
199
+ "expires_in": max(0, int(expires_at - now)),
200
+ })
201
+ records.sort(key=lambda item: float(item["expires_at"]), reverse=True)
202
+ return records
203
+
204
+
205
+ def first_run_stage(*, installed: bool, running: bool, pair_window_open: bool, remote_ready: bool) -> str:
206
+ if not installed:
207
+ return "helper_missing"
208
+ if not running:
209
+ return "runtime_not_ready"
210
+ if remote_ready and pair_window_open:
211
+ return "remote_ready"
212
+ if pair_window_open:
213
+ return "pair_window_open"
214
+ if not remote_ready:
215
+ return "remote_route_missing"
216
+ return "helper_running"
217
+
218
+
219
+ def next_action_for_stage(stage: str, *, remote_status: str, pair_window_open: bool) -> dict:
220
+ if stage == "remote_ready":
221
+ return {
222
+ "id": "pair_iphone",
223
+ "label": "Pair iPhone",
224
+ "message": "Open Pairling on iPhone and pair with this Mac.",
225
+ }
226
+ if pair_window_open and remote_status != "ready":
227
+ return {
228
+ "id": "pair_local_or_retry_connect",
229
+ "label": "Pair locally or retry Connect",
230
+ "message": "A local pairing invitation is open. Pair locally now, or retry Pairling Connect after this Mac is ready.",
231
+ }
232
+ if stage == "remote_route_missing":
233
+ return {
234
+ "id": "authenticate_pairling_connect",
235
+ "label": "Authenticate Pairling Connect",
236
+ "message": "Approve Pairling Connect in the browser, then recheck this Mac.",
237
+ }
238
+ if stage == "helper_running":
239
+ return {
240
+ "id": "open_pairing_invitation",
241
+ "label": "Open pairing invitation",
242
+ "message": "Open a pairing invitation from Pairling Helper, then pair from the iPhone.",
243
+ }
244
+ if stage == "runtime_not_ready":
245
+ return {
246
+ "id": "start_helper",
247
+ "label": "Start Pairling Helper",
248
+ "message": "Run Pairling Helper setup and review the failing runtime checks.",
249
+ }
250
+ return {
251
+ "id": "install_helper",
252
+ "label": "Install Pairling Helper",
253
+ "message": "Install Pairling Helper on this Mac before pairing.",
254
+ }
255
+
256
+
257
+ manifest = None
258
+ if MANIFEST_PATH.is_file():
259
+ try:
260
+ manifest = json.loads(MANIFEST_PATH.read_text())
261
+ add("manifest_exists", True, "error", "Installed Pairling manifest exists.", str(MANIFEST_PATH))
262
+ except Exception as exc:
263
+ add("manifest_exists", False, "error", f"Manifest is unreadable: {type(exc).__name__}: {exc}", str(MANIFEST_PATH))
264
+ else:
265
+ add("manifest_exists", False, "error", "Installed Pairling manifest is missing.", str(MANIFEST_PATH))
266
+
267
+ if manifest:
268
+ add("manifest_contract", manifest.get("contract_version") == "pairling-runtime-v1", "error", "Manifest contract is pairling-runtime-v1.", manifest.get("contract_version"))
269
+ runtime = manifest.get("runtime") if isinstance(manifest.get("runtime"), dict) else {}
270
+ add("runtime_port", runtime.get("port") == PAIRLING_PORT, "error", "Runtime port is locked to 7773.", runtime.get("port"))
271
+ launchd = manifest.get("launchd") if isinstance(manifest.get("launchd"), dict) else {}
272
+ add("launchd_labels", launchd.get("daemon_label") == PAIRLING_LABEL and launchd.get("connectd_label") == PAIRLING_CONNECTD_LABEL and launchd.get("guardian_label") == PAIRLING_GUARDIAN_LABEL, "error", "Manifest launchd labels are Pairling labels.", launchd)
273
+ mismatches = []
274
+ for item in manifest.get("files") or []:
275
+ rel = item.get("path")
276
+ expected = item.get("sha256")
277
+ if not rel or not expected:
278
+ mismatches.append(f"malformed file entry: {item}")
279
+ continue
280
+ path = CURRENT / rel
281
+ if not path.is_file():
282
+ mismatches.append(f"missing {rel}")
283
+ continue
284
+ actual = sha256_file(path)
285
+ if actual != expected:
286
+ mismatches.append(f"{rel}: {actual} != {expected}")
287
+ add("manifest_hashes", not mismatches, "error", "Installed file hashes match manifest." if not mismatches else "Installed file hashes do not match manifest.", mismatches)
288
+ else:
289
+ add("manifest_contract", False, "error", "Cannot validate contract without manifest.")
290
+ add("runtime_port", False, "error", "Cannot validate runtime port without manifest.")
291
+ add("launchd_labels", False, "error", "Cannot validate labels without manifest.")
292
+ add("manifest_hashes", False, "error", "Cannot validate hashes without manifest.")
293
+
294
+ compile_targets = [
295
+ repo_root / "mac" / "install" / "render-launchd.py",
296
+ repo_root / "mac" / "guardian" / "guardian_contract.py",
297
+ repo_root / "mac" / "guardian" / "companion-power-guardian.py",
298
+ ]
299
+ compile_errors = []
300
+ for target in compile_targets:
301
+ code, out, err = run(["python3", "-m", "py_compile", str(target)])
302
+ if code != 0:
303
+ compile_errors.append(f"{target}: {err or out}")
304
+ add("lifecycle_sources_compile", not compile_errors, "error", "Lifecycle/guardian sources compile." if not compile_errors else "Lifecycle/guardian compile failed.", compile_errors)
305
+
306
+ ok, evidence = writable_dir(APP_SUPPORT)
307
+ add("app_support_writable", ok, "error", "App support directory is writable.", evidence)
308
+ ok, evidence = writable_dir(LOGS_ROOT)
309
+ add("logs_writable", ok, "error", "Logs directory is writable.", evidence)
310
+
311
+ if DEVICES_DB.exists():
312
+ try:
313
+ with sqlite3.connect(f"file:{DEVICES_DB}?mode=ro", uri=True) as db:
314
+ tables = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
315
+ add("devices_db", {"devices", "audit_events"}.issubset(tables), "error", "Devices database has required tables.", sorted(tables))
316
+ except Exception as exc:
317
+ add("devices_db", False, "error", f"Devices database is unreadable: {type(exc).__name__}: {exc}", str(DEVICES_DB))
318
+ else:
319
+ add("devices_db", False, "error", "Devices database is missing.", str(DEVICES_DB))
320
+
321
+ try:
322
+ sys.path.insert(0, str(repo_root / "mac" / "companiond"))
323
+ from pairling_devices import DeviceRegistry
324
+ from local_mcp_bridge import validate_local_mcp_bridge_credential
325
+
326
+ valid, evidence = validate_local_mcp_bridge_credential(
327
+ registry=DeviceRegistry(DEVICES_DB, LOGS_ROOT / "audit.jsonl"),
328
+ credential_path=MCP_CREDENTIAL,
329
+ )
330
+ add(
331
+ "mcp_bridge_credential",
332
+ valid,
333
+ "error",
334
+ "Local Pairling MCP bridge credential is valid and scoped.",
335
+ evidence,
336
+ )
337
+ except Exception as exc:
338
+ add(
339
+ "mcp_bridge_credential",
340
+ False,
341
+ "error",
342
+ f"Local Pairling MCP bridge credential is invalid: {type(exc).__name__}: {exc}",
343
+ str(MCP_CREDENTIAL),
344
+ )
345
+
346
+ if MCP_ADAPTER.exists():
347
+ add("mcp_adapter_installed", True, "error", "Repo-owned Pairling MCP adapter is installed in runtime/current.", str(MCP_ADAPTER))
348
+ else:
349
+ add("mcp_adapter_installed", False, "error", "Repo-owned Pairling MCP adapter is missing from runtime/current.", str(MCP_ADAPTER))
350
+
351
+ try:
352
+ shim_text = MCP_SHIM.read_text()
353
+ add(
354
+ "mcp_adapter_shim",
355
+ "Pairling daemon-first phone-tools MCP server" in shim_text and "PAIRLING_MCP_ADAPTER" in shim_text,
356
+ "warning",
357
+ "Installed phone-tools MCP shim points at Pairling.",
358
+ str(MCP_SHIM),
359
+ )
360
+ except Exception as exc:
361
+ add("mcp_adapter_shim", False, "warning", f"Installed phone-tools MCP shim is missing: {type(exc).__name__}: {exc}", str(MCP_SHIM))
362
+
363
+ try:
364
+ pairling_text = USER_PAIRLING.read_text()
365
+ add(
366
+ "shell_pairling_wrapper",
367
+ "runtime/current/bin/pairling" in pairling_text
368
+ and "/Users/mergimg0/projects/Pairling" not in pairling_text,
369
+ "error",
370
+ "User pairling command resolves through runtime/current unless PAIRLING_REPO_ROOT is explicitly set.",
371
+ str(USER_PAIRLING),
372
+ )
373
+ except Exception as exc:
374
+ add("shell_pairling_wrapper", False, "error", f"User pairling command is missing or unreadable: {type(exc).__name__}: {exc}", str(USER_PAIRLING))
375
+
376
+ if PAIR_ROOT.exists():
377
+ mode = PAIR_ROOT.stat().st_mode & 0o777
378
+ add("pair_storage_permissions", mode <= 0o700, "error", "Pair storage permissions are private.", oct(mode))
379
+ else:
380
+ add("pair_storage_permissions", False, "error", "Pair storage directory is missing.", str(PAIR_ROOT))
381
+
382
+ try:
383
+ payload = load_plist(USER_PLIST)
384
+ args = payload.get("ProgramArguments") or []
385
+ env = payload.get("EnvironmentVariables") or {}
386
+ add("launchagent_plist", payload.get("Label") == PAIRLING_LABEL and any(str(CURRENT / "companiond" / "pairlingd.py") == value for value in args), "error", "Pairling LaunchAgent points at runtime/current.", {"label": payload.get("Label"), "args": args})
387
+ add("launchagent_port_env", env.get("PAIRLING_RUNTIME_PORT") == str(PAIRLING_PORT), "error", "Pairling LaunchAgent advertises port 7773.", env)
388
+ except Exception as exc:
389
+ add("launchagent_plist", False, "error", f"Pairling LaunchAgent plist unreadable: {type(exc).__name__}: {exc}", str(USER_PLIST))
390
+ add("launchagent_port_env", False, "error", "Cannot validate Pairling LaunchAgent environment.", str(USER_PLIST))
391
+
392
+ try:
393
+ payload = load_plist(CONNECTD_USER_PLIST)
394
+ args = payload.get("ProgramArguments") or []
395
+ env = payload.get("EnvironmentVariables") or {}
396
+ add(
397
+ "connectd_launchagent_plist",
398
+ payload.get("Label") == PAIRLING_CONNECTD_LABEL and any(str(CURRENT / "connectd" / "pairling-connectd") == value for value in args),
399
+ "error",
400
+ "Pairling Connect LaunchAgent points at runtime/current.",
401
+ {"label": payload.get("Label"), "args": args},
402
+ )
403
+ add(
404
+ "connectd_launchagent_env",
405
+ env.get("PAIRLING_RUNTIME_PORT") == str(PAIRLING_PORT),
406
+ "error",
407
+ "Pairling Connect LaunchAgent advertises port 7773.",
408
+ env,
409
+ )
410
+ except Exception as exc:
411
+ add("connectd_launchagent_plist", False, "error", f"Pairling Connect LaunchAgent plist unreadable: {type(exc).__name__}: {exc}", str(CONNECTD_USER_PLIST))
412
+ add("connectd_launchagent_env", False, "error", "Cannot validate Pairling Connect LaunchAgent environment.", str(CONNECTD_USER_PLIST))
413
+
414
+ try:
415
+ payload = load_plist(SYSTEM_PLIST)
416
+ add("guardian_plist", payload.get("Label") == PAIRLING_GUARDIAN_LABEL, "warning", "Pairling guardian LaunchDaemon is rendered/installed.", {"label": payload.get("Label")})
417
+ except Exception as exc:
418
+ add("guardian_plist", False, "warning", f"Pairling guardian LaunchDaemon is not installed: {type(exc).__name__}: {exc}", str(SYSTEM_PLIST))
419
+
420
+ code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{PAIRLING_LABEL}"])
421
+ add("launchagent_loaded", code == 0 and "state = running" in out, "error", "Pairling LaunchAgent is running." if code == 0 else "Pairling LaunchAgent is not loaded.", (out or err)[:2000])
422
+ add("launchagent_loaded_from_current", str(CURRENT / "companiond" / "pairlingd.py") in out, "error", "Loaded Pairling LaunchAgent uses runtime/current.", out[:2000])
423
+
424
+ code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{PAIRLING_CONNECTD_LABEL}"])
425
+ add("connectd_launchagent_loaded", code == 0 and "state = running" in out, "error", "Pairling Connect LaunchAgent is running." if code == 0 else "Pairling Connect LaunchAgent is not loaded.", (out or err)[:2000])
426
+ add("connectd_loaded_from_current", str(CURRENT / "connectd" / "pairling-connectd") in out, "error", "Loaded Pairling Connect LaunchAgent uses runtime/current.", out[:2000])
427
+
428
+ code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{LEGACY_LABEL}"])
429
+ legacy_loaded = code == 0 and "state = running" in out
430
+ add("legacy_daemon_unloaded", not legacy_loaded, "error", "Old Pairling predecessor launchd label is not loaded.", (out or err)[:2000])
431
+ add("legacy_launchagent_removed", not LEGACY_USER_PLIST.exists(), "warning", "Legacy user LaunchAgent plist is absent.", str(LEGACY_USER_PLIST))
432
+ add("legacy_guardian_removed", not LEGACY_SYSTEM_PLIST.exists(), "warning", "Legacy guardian LaunchDaemon plist is absent.", str(LEGACY_SYSTEM_PLIST))
433
+
434
+ listeners_7773 = port_listeners(PAIRLING_PORT)
435
+ listeners_7723 = port_listeners(7723)
436
+ add("port_7773_listener", bool(listeners_7773) or tcp_accepts("127.0.0.1", PAIRLING_PORT), "error", "Runtime is listening on 7773.", listeners_7773)
437
+ legacy_conflict = any("notify-webhook" in line or "Python" in line or "python" in line for line in listeners_7723)
438
+ add("legacy_port_7723_clear", not legacy_conflict, "error", "Legacy 7723 daemon is not conflicting.", listeners_7723)
439
+
440
+ health = None
441
+ try:
442
+ req = urllib.request.Request(f"http://127.0.0.1:{PAIRLING_PORT}/health")
443
+ with urllib.request.urlopen(req, timeout=3) as resp:
444
+ health = json.loads(resp.read().decode("utf-8"))
445
+ add("health_endpoint", resp.status == 200, "error", "GET /health returned HTTP 200.", resp.status)
446
+ except Exception as exc:
447
+ add("health_endpoint", False, "error", f"GET /health failed: {type(exc).__name__}: {exc}", f"http://127.0.0.1:{PAIRLING_PORT}/health")
448
+
449
+ if health:
450
+ add("health_contract", health.get("contract_version") == "pairling-runtime-v1", "error", "/health reports Pairling runtime contract.", health.get("contract_version"))
451
+ else:
452
+ add("health_contract", False, "error", "Cannot validate /health contract without response.")
453
+
454
+ connectd_status = fetch_connectd_status()
455
+ connectd_summary = redacted_connectd_summary(connectd_status)
456
+ add(
457
+ "connectd_status_schema_v2",
458
+ int(connectd_status.get("schema_version") or 0) >= 2,
459
+ "error",
460
+ "Pairling Connect status uses schema v2.",
461
+ connectd_summary,
462
+ )
463
+ add(
464
+ "connectd_status_redacted",
465
+ re.search(r"https://login\.tailscale\.com/a/(?!\[redacted\])", json.dumps(connectd_status, sort_keys=True)) is None,
466
+ "error",
467
+ "Pairling Connect status does not expose browser auth URLs.",
468
+ connectd_summary,
469
+ )
470
+
471
+ provider_evidence = {}
472
+ for name in ["claude", "codex"]:
473
+ code, out, _ = run(["/usr/bin/which", name], timeout=2)
474
+ provider_evidence[name] = out.strip() if code == 0 else None
475
+ add("provider_clis_detected", True, "warning", "Provider CLI detection completed.", provider_evidence)
476
+
477
+ release_blockers = []
478
+ developer_id_identity = os.environ.get("PAIRLING_DEVELOPER_ID_IDENTITY", "Developer ID Application: Mergim Gashi (965AVD34A3)")
479
+ code, out, err = run(["/usr/bin/security", "find-identity", "-v", "-p", "codesigning"], timeout=5)
480
+ has_developer_id = code == 0 and developer_id_identity in out
481
+ if not has_developer_id:
482
+ release_blockers.append(f"Developer ID identity is missing from the login keychain: {developer_id_identity}")
483
+ add(
484
+ "developer_id_identity",
485
+ has_developer_id,
486
+ "warning",
487
+ "Developer ID Application identity is available for public helper signing.",
488
+ (out or err)[:2000],
489
+ )
490
+
491
+ notary_profile = os.environ.get("PAIRLING_NOTARY_PROFILE", "pairling-notary")
492
+ code, out, err = run(["/usr/bin/xcrun", "notarytool", "history", "--keychain-profile", notary_profile], timeout=10)
493
+ has_notary_profile = code == 0
494
+ if not has_notary_profile:
495
+ release_blockers.append(f"Notary credentials are missing or invalid for keychain profile: {notary_profile}")
496
+ add(
497
+ "notary_profile",
498
+ has_notary_profile,
499
+ "warning",
500
+ "Notary credentials are stored and can authenticate.",
501
+ (out or err)[:2000],
502
+ )
503
+
504
+ helper_artifact = Path(os.environ.get("PAIRLING_HELPER_ARTIFACT", str(repo_root / "dist" / "PairlingHelper.dmg")))
505
+ helper_bundle = APP_SUPPORT / "Pairling Helper.app"
506
+ if helper_artifact.exists():
507
+ code, out, err = run([
508
+ "/usr/sbin/spctl",
509
+ "-a",
510
+ "-vv",
511
+ "--type",
512
+ "open",
513
+ "--context",
514
+ "context:primary-signature",
515
+ str(helper_artifact),
516
+ ], timeout=8)
517
+ if code != 0:
518
+ release_blockers.append("Developer ID signed/notarized helper DMG is not Gatekeeper-accepted yet.")
519
+ add(
520
+ "helper_signing_notarization",
521
+ code == 0,
522
+ "warning",
523
+ "Helper DMG Gatekeeper assessment.",
524
+ {"artifact": str(helper_artifact), "assessment": (out or err)[:2000]},
525
+ )
526
+ elif helper_bundle.exists():
527
+ code, out, err = run(["/usr/sbin/spctl", "-a", "-vv", str(helper_bundle)], timeout=8)
528
+ add("helper_signing_notarization", code == 0, "warning", "Helper bundle Gatekeeper assessment.", (out or err)[:2000])
529
+ else:
530
+ release_blockers.append("Developer ID signed/notarized helper artifact is not present yet.")
531
+ add(
532
+ "helper_signing_notarization",
533
+ False,
534
+ "warning",
535
+ "Helper artifact not present; signing/notarization remains a release blocker.",
536
+ {"artifact": str(helper_artifact), "bundle": str(helper_bundle)},
537
+ )
538
+
539
+ errors = [c for c in checks if c["status"] != "ok" and c["severity"] == "error"]
540
+ warnings = [c for c in checks if c["status"] != "ok" and c["severity"] == "warning"]
541
+ checks_by_id = {c["id"]: c for c in checks}
542
+ active_pairs = active_pair_records(PAIR_ROOT)
543
+ runtime_installed = checks_by_id.get("manifest_exists", {}).get("status") == "ok"
544
+ runtime_running = checks_by_id.get("health_endpoint", {}).get("status") == "ok"
545
+ runtime_running_for_first_run = runtime_running or os.environ.get("PAIRLING_TEST_FIRST_RUN_RUNTIME_READY") == "1"
546
+ pair_window_open = bool(active_pairs)
547
+ tailnet_ip = detected_tailnet_ip()
548
+ remote_ready = bool(connectd_summary.get("route_ready"))
549
+ remote_status = "ready" if remote_ready else str(connectd_summary.get("status") or "missing_mac")
550
+ local_pairing_ready = runtime_installed and runtime_running_for_first_run and pair_window_open
551
+ product_ready = local_pairing_ready and remote_ready
552
+ stage = first_run_stage(
553
+ installed=runtime_installed,
554
+ running=runtime_running_for_first_run,
555
+ pair_window_open=pair_window_open,
556
+ remote_ready=remote_ready,
557
+ )
558
+ first_run = {
559
+ "ok": local_pairing_ready,
560
+ "schema_version": 2,
561
+ "stage": stage,
562
+ "product_ready": product_ready,
563
+ "local_pairing_ready": local_pairing_ready,
564
+ "helper": {
565
+ "installed": runtime_installed,
566
+ "running": runtime_running_for_first_run,
567
+ "runtime_health_verified": runtime_running,
568
+ "launchd_label": PAIRLING_LABEL,
569
+ "artifact_release_blockers": release_blockers,
570
+ },
571
+ "runtime": {
572
+ "installed": runtime_installed,
573
+ "running": runtime_running_for_first_run,
574
+ "health_verified": runtime_running,
575
+ "port": PAIRLING_PORT,
576
+ "launchd_label": PAIRLING_LABEL,
577
+ },
578
+ "remote_access": {
579
+ "required_for_product_ready": True,
580
+ "provider": "pairling_connect",
581
+ "status": remote_status,
582
+ "mac_tailnet_ip": (connectd_summary.get("route") or {}).get("host") if isinstance(connectd_summary.get("route"), dict) else None,
583
+ "iphone_tailnet_detected": "unknown_until_route_used",
584
+ "preferred_remote_route": (connectd_summary.get("route") or {}).get("base_url") if isinstance(connectd_summary.get("route"), dict) else None,
585
+ "local_pairing_available": runtime_installed and runtime_running_for_first_run,
586
+ "bonjour_available": pair_window_open,
587
+ "standalone_tailnet_diagnostic_ip": tailnet_ip,
588
+ },
589
+ "connect": connectd_summary,
590
+ "pairing": {
591
+ "pair_window_open": pair_window_open,
592
+ "active_pair_count": len(active_pairs),
593
+ "active_pairs": active_pairs[:3],
594
+ "expires_in": active_pairs[0]["expires_in"] if active_pairs else None,
595
+ "bonjour": "advertised_by_pair_start_if_dns_sd_available" if pair_window_open else "open_pairing_invitation_to_advertise",
596
+ "qr_fallback": "available_from_pairling_pair_qr",
597
+ "manual_url_fallback": "available_from_pairling_pair_json",
598
+ },
599
+ "routes": {
600
+ "localhost": tcp_accepts("127.0.0.1", PAIRLING_PORT),
601
+ "lan": "verified_after_pair_claim_host_chain",
602
+ "tailscale": remote_status,
603
+ "pairling_connect": remote_status,
604
+ },
605
+ "permissions": permission_readiness(),
606
+ "provider_readiness": {
607
+ "status": "checked_by_runtime_after_pairing",
608
+ "detected_clis": provider_evidence,
609
+ },
610
+ "next_action": next_action_for_stage(stage, remote_status=remote_status, pair_window_open=pair_window_open),
611
+ }
612
+ result = {
613
+ "ok": not errors,
614
+ "product": "Pairling",
615
+ "schema_version": 1,
616
+ "contract_version": "pairling-runtime-v1",
617
+ "runtime": {
618
+ "name": "pairlingd",
619
+ "port": PAIRLING_PORT,
620
+ "launchd_label": PAIRLING_LABEL,
621
+ "guardian_label": PAIRLING_GUARDIAN_LABEL,
622
+ },
623
+ "paths": {
624
+ "app_support": str(APP_SUPPORT),
625
+ "logs": str(LOGS_ROOT),
626
+ "current": str(CURRENT),
627
+ "devices_db": str(DEVICES_DB),
628
+ "pair_records": str(PAIR_ROOT),
629
+ },
630
+ "legacy": {
631
+ "daemon_label": LEGACY_LABEL,
632
+ "port": 7723,
633
+ "loaded": legacy_loaded,
634
+ "listeners": listeners_7723,
635
+ },
636
+ "release_blockers": release_blockers,
637
+ "checks": checks,
638
+ "warnings": warnings,
639
+ "errors": errors,
640
+ }
641
+ if first_run_mode:
642
+ result["first_run"] = first_run
643
+
644
+ if json_mode:
645
+ print(json.dumps(result, indent=2, sort_keys=True))
646
+ else:
647
+ print(f"Pairling runtime doctor: {'ok' if result['ok'] else 'failed'}")
648
+ if first_run_mode:
649
+ print(f"First-run stage: {first_run['stage']}")
650
+ next_action = first_run.get("next_action")
651
+ if isinstance(next_action, dict):
652
+ print(f"Next action: {next_action.get('message', next_action.get('label', 'Review first-run readiness.'))}")
653
+ else:
654
+ print(f"Next action: {next_action}")
655
+ for item in checks:
656
+ marker = "ok" if item["status"] == "ok" else item["severity"]
657
+ print(f"[{marker}] {item['id']}: {item['summary']}")
658
+
659
+ raise SystemExit(0 if result["ok"] else 1)
660
+ PY