pairling 0.1.0 → 0.2.1

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 (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/payload/mac/SOURCE_BRANCH +1 -1
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/app_attest_lan.py +87 -0
  7. package/payload/mac/companiond/codex_approval.py +69 -0
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +374 -70
  10. package/payload/mac/companiond/pairling_psk.py +100 -0
  11. package/payload/mac/companiond/pairling_tools.py +2 -2
  12. package/payload/mac/companiond/pairlingd.py +977 -104
  13. package/payload/mac/companiond/pty_broker.py +441 -3
  14. package/payload/mac/companiond/pty_broker_client.py +167 -0
  15. package/payload/mac/companiond/pty_broker_service.py +84 -0
  16. package/payload/mac/companiond/runtime_contract.py +0 -2
  17. package/payload/mac/companiond/standard_push_publisher.py +7 -0
  18. package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
  19. package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
  22. package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
  23. package/payload/mac/connectd/internal/status/status.go +9 -0
  24. package/payload/mac/install/doctor.sh +227 -51
  25. package/payload/mac/install/install-runtime.sh +398 -15
  26. package/payload/mac/install/psk_dependency_check.py +40 -0
  27. package/payload/mac/install/render-launchd.py +23 -0
  28. package/payload/mac/install/uninstall-runtime.sh +4 -12
  29. package/payload-manifest.json +51 -23
@@ -34,7 +34,6 @@ PACKAGED_SOURCE_PATHS=(
34
34
  "mac/connectd/go.mod"
35
35
  "mac/connectd/go.sum"
36
36
  "mac/guardian"
37
- "mac/helper-assistant/PairlingHelperAssistant.swift"
38
37
  "mac/install"
39
38
  "mac/mcp"
40
39
  )
@@ -51,7 +50,7 @@ PAIRLING_RUNTIME_PORT="${PAIRLING_RUNTIME_PORT:-7773}"
51
50
  PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
52
51
  PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
53
52
  PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
54
- LEGACY_DAEMON_LABEL="com.mghome.notify-webhook"
53
+ PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
55
54
  APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
56
55
  RUNTIME_ROOT="$APP_SUPPORT/runtime"
57
56
  RELEASES_ROOT="$RUNTIME_ROOT/releases"
@@ -69,19 +68,34 @@ MCP_CREDENTIAL="$APP_SUPPORT/mcp-bridge.json"
69
68
  INSTALL_HISTORY="$STATE_ROOT/install-history.jsonl"
70
69
  USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
71
70
  CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
72
- LEGACY_USER_PLIST="$HOME/Library/LaunchAgents/$LEGACY_DAEMON_LABEL.plist"
71
+ PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
73
72
  SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
74
- LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
75
73
  MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
76
74
  MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
77
75
  PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
78
76
  GUARDIAN_PYTHON_BIN="${PAIRLING_GUARDIAN_PYTHON:-${COMPANION_GUARDIAN_PYTHON:-/usr/bin/python3}}"
77
+ # P3 Python custody: the npm shim points PAIRLING_DAEMON_PYTHON at the vendored
78
+ # CPython inside the platform runtime package (…/python/bin/python3). When that
79
+ # is in play we stage the whole interpreter into the release tree and run the
80
+ # daemon under it, so a Pairling-signed python (identity dev.pairling.python),
81
+ # not a generic system python3, owns the daemon's TCC grants — and npm churn
82
+ # can't remove the running interpreter.
83
+ PYTHON_CODESIGN_IDENTIFIER="dev.pairling.python"
79
84
  DRY_RUN="${PAIRLING_DRY_RUN:-0}"
80
85
 
81
86
  log() {
82
87
  printf '%s\n' "$*"
83
88
  }
84
89
 
90
+ display_path() {
91
+ local path="$1"
92
+ case "$path" in
93
+ "$HOME"/*) printf '~/%s\n' "${path#"$HOME"/}" ;;
94
+ "$HOME") printf '~\n' ;;
95
+ *) printf '%s\n' "$path" ;;
96
+ esac
97
+ }
98
+
85
99
  is_dry_run() {
86
100
  [[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
87
101
  }
@@ -122,9 +136,13 @@ run_compile_checks() {
122
136
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/llm_route.py"
123
137
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_tools.py"
124
138
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_pairing.py"
139
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_psk.py"
125
140
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_relay_claims.py"
126
141
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/request_proof.py"
142
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/codex_approval.py"
127
143
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker.py"
144
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker_client.py"
145
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker_service.py"
128
146
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_screen_backend.py"
129
147
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py"
130
148
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/push_dispatcher.py"
@@ -150,9 +168,30 @@ run_compile_checks() {
150
168
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/companion-power-guardian.py"
151
169
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/guardian_contract.py"
152
170
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/render-launchd.py"
171
+ PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/psk_dependency_check.py"
153
172
  rm -rf "$pycache_root"
154
173
  }
155
174
 
175
+ run_psk_dependency_import_check() {
176
+ local python_bin="$1"
177
+ local companiond_path="$2"
178
+ local label="$3"
179
+ "$python_bin" "$REPO_ROOT/mac/install/psk_dependency_check.py" "$companiond_path" --label "$label"
180
+ }
181
+
182
+ run_psk_dependency_checks() {
183
+ run_psk_dependency_import_check "$PYTHON3_BIN" "$REPO_ROOT/mac/companiond" "source-tree preflight"
184
+ }
185
+
186
+ run_staged_psk_dependency_checks() {
187
+ local tmp="$1"
188
+ local staged_python="$PYTHON3_BIN"
189
+ if [[ -x "$tmp/python/bin/python3" ]]; then
190
+ staged_python="$tmp/python/bin/python3"
191
+ fi
192
+ run_psk_dependency_import_check "$staged_python" "$tmp/companiond" "staged runtime copy"
193
+ }
194
+
156
195
  ensure_state() {
157
196
  mkdir -p "$RELEASES_ROOT" "$STATE_ROOT" "$PAIR_ROOT" "$LOGS_ROOT" "$PLIST_BUILD_DIR" "$APP_SUPPORT/modules"
158
197
  chmod 700 "$APP_SUPPORT" "$PAIR_ROOT" 2>/dev/null || true
@@ -242,9 +281,13 @@ copy_release() {
242
281
  cp "$REPO_ROOT/mac/companiond/llm_route.py" "$tmp/companiond/"
243
282
  cp "$REPO_ROOT/mac/companiond/pairling_tools.py" "$tmp/companiond/"
244
283
  cp "$REPO_ROOT/mac/companiond/pairling_pairing.py" "$tmp/companiond/"
284
+ cp "$REPO_ROOT/mac/companiond/pairling_psk.py" "$tmp/companiond/"
245
285
  cp "$REPO_ROOT/mac/companiond/pairling_relay_claims.py" "$tmp/companiond/"
246
286
  cp "$REPO_ROOT/mac/companiond/request_proof.py" "$tmp/companiond/"
287
+ cp "$REPO_ROOT/mac/companiond/codex_approval.py" "$tmp/companiond/"
247
288
  cp "$REPO_ROOT/mac/companiond/pty_broker.py" "$tmp/companiond/"
289
+ cp "$REPO_ROOT/mac/companiond/pty_broker_client.py" "$tmp/companiond/"
290
+ cp "$REPO_ROOT/mac/companiond/pty_broker_service.py" "$tmp/companiond/"
248
291
  cp "$REPO_ROOT/mac/companiond/terminal_screen_backend.py" "$tmp/companiond/"
249
292
  cp "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py" "$tmp/companiond/"
250
293
  cp "$REPO_ROOT/mac/companiond/push_dispatcher.py" "$tmp/companiond/"
@@ -263,6 +306,8 @@ copy_release() {
263
306
  cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
264
307
  cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
265
308
  build_connectd_binary "$tmp/connectd/pairling-connectd"
309
+ stage_vendored_python "$tmp/python"
310
+ run_staged_psk_dependency_checks "$tmp"
266
311
  copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
267
312
  write_installed_pairling_launcher "$tmp/bin/pairling"
268
313
  chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
@@ -294,6 +339,10 @@ copy_runtime_source_tree() {
294
339
  printf '%s\n' "$BRANCH" > "$mac_root/SOURCE_BRANCH"
295
340
  printf '%s\n' "$SOURCE_DIRTY" > "$mac_root/SOURCE_DIRTY"
296
341
  cp "$REPO_ROOT/mac/companiond/"*.py "$mac_root/companiond/"
342
+ # WS2: co-locate the canonical App Attest validator with the daemon so
343
+ # app_attest_lan can import it in the staged runtime (the repo keeps the one
344
+ # source of truth in relay/). Non-fatal if absent — the gate fails closed.
345
+ cp "$REPO_ROOT/relay/app_attest_validator.py" "$mac_root/companiond/" 2>/dev/null || true
297
346
  cp "$REPO_ROOT/mac/companiond/providers/"*.py "$mac_root/companiond/providers/"
298
347
  cp "$REPO_ROOT/mac/companiond/integrations/__init__.py" "$mac_root/companiond/integrations/"
299
348
  cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$mac_root/companiond/integrations/aperture_cli/"
@@ -322,6 +371,59 @@ exec "$ROOT/mac/packaging/bin/pairling" "$@"
322
371
  SH
323
372
  }
324
373
 
374
+ # Stage the vendored CPython (P3 custody) into the release tree when the npm
375
+ # shim provided one via PAIRLING_DAEMON_PYTHON pointing at …/python/bin/python3.
376
+ # Fail-closed: the interpreter must carry a valid signature, the pinned Team ID,
377
+ # and the dev.pairling.python identifier. On success, repoint PYTHON3_BIN at the
378
+ # STAGED interpreter so the daemon plist never references the npm package path.
379
+ stage_vendored_python() {
380
+ local dest="$1"
381
+ local provided="${PAIRLING_DAEMON_PYTHON:-}"
382
+ # Only act on a vendored interpreter living under a runtime package's python/
383
+ # tree. A bare system python3 (no sibling python/ tree) is left as-is.
384
+ case "$provided" in
385
+ */python/bin/python3) : ;;
386
+ *) return 0 ;;
387
+ esac
388
+ local src_tree
389
+ src_tree="$(cd "$(dirname "$provided")/.." && pwd)"
390
+ if [[ ! -x "$src_tree/bin/python3" ]]; then
391
+ return 0
392
+ fi
393
+ local required_team="${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}"
394
+ # Always enforce signature integrity and the dev.pairling.python identity
395
+ # (cert-independent defense in depth). Pin the Apple Team ID unless the dev
396
+ # switch (-) disables that one check for local ad-hoc builds.
397
+ if ! /usr/bin/codesign --verify --strict "$src_tree/bin/python3" >/dev/null 2>&1; then
398
+ log "ERROR: vendored python failed codesign verification; refusing to stage: $src_tree/bin/python3" >&2
399
+ exit 1
400
+ fi
401
+ local team identifier
402
+ identifier="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^Identifier=//p')"
403
+ if [[ "$identifier" != "$PYTHON_CODESIGN_IDENTIFIER" ]]; then
404
+ log "ERROR: vendored python identifier '${identifier:-none}' is not '$PYTHON_CODESIGN_IDENTIFIER'; refusing to stage." >&2
405
+ exit 1
406
+ fi
407
+ if [[ "$required_team" == "-" ]]; then
408
+ log "WARNING: vendored python Team ID pin disabled (PAIRLING_CONNECTD_TEAM_ID=-). Dev builds only."
409
+ else
410
+ team="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
411
+ if [[ "$team" != "$required_team" ]]; then
412
+ log "ERROR: vendored python TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage." >&2
413
+ exit 1
414
+ fi
415
+ fi
416
+ rm -rf "$dest"
417
+ mkdir -p "$(dirname "$dest")"
418
+ cp -R "$src_tree" "$dest"
419
+ chmod 755 "$dest/bin/python3" 2>/dev/null || true
420
+ # Point the daemon at the interpreter through the stable `current` symlink
421
+ # (not $dest, which is the pre-move temp path) so the plist resolves after the
422
+ # release is moved into place and after rollback — exactly like connectd.
423
+ PYTHON3_BIN="$CURRENT_LINK/python/bin/python3"
424
+ log "Staged vendored CPython (daemon will run under dev.pairling.python via $PYTHON3_BIN)"
425
+ }
426
+
325
427
  build_connectd_binary() {
326
428
  local out="$1"
327
429
  # npm-delivered binary: the shim points PAIRLING_CONNECTD_PREBUILT at the
@@ -404,9 +506,13 @@ for rel in [
404
506
  "companiond/llm_route.py",
405
507
  "companiond/pairling_tools.py",
406
508
  "companiond/pairling_pairing.py",
509
+ "companiond/pairling_psk.py",
407
510
  "companiond/pairling_relay_claims.py",
408
511
  "companiond/request_proof.py",
512
+ "companiond/codex_approval.py",
409
513
  "companiond/pty_broker.py",
514
+ "companiond/pty_broker_client.py",
515
+ "companiond/pty_broker_service.py",
410
516
  "companiond/terminal_screen_backend.py",
411
517
  "companiond/terminal_text_sanitizer.py",
412
518
  "companiond/push_dispatcher.py",
@@ -459,9 +565,9 @@ manifest = {
459
565
  },
460
566
  "launchd": {
461
567
  "daemon_label": "dev.pairling.companiond",
568
+ "ptybroker_label": "dev.pairling.ptybroker",
462
569
  "connectd_label": "dev.pairling.connectd",
463
570
  "guardian_label": "dev.pairling.power-guardian",
464
- "legacy_daemon_label": "com.mghome.notify-webhook",
465
571
  },
466
572
  "paths": {
467
573
  "app_support": app_support,
@@ -551,7 +657,7 @@ if [[ -x "$RUNTIME_PAIRLING" ]]; then
551
657
  exec "$RUNTIME_PAIRLING" "$@"
552
658
  fi
553
659
 
554
- printf 'Pairling runtime command is not installed. Open Pairling Helper and run Start setup, or use a repo-local mac/packaging/bin/pairling.\n' >&2
660
+ printf 'Pairling runtime command is not installed. Run: npm install -g pairling && pairling setup (or use a repo-local mac/packaging/bin/pairling).\n' >&2
555
661
  exit 127
556
662
  SH
557
663
  chmod 755 "$tmp"
@@ -559,21 +665,26 @@ SH
559
665
  }
560
666
 
561
667
  render_plists() {
668
+ # Prefer the staged vendored interpreter whenever it exists, so start/
669
+ # rollback (which don't re-stage) also run the daemon under dev.pairling.python.
670
+ local daemon_python="$PYTHON3_BIN"
671
+ if [[ -x "$CURRENT_LINK/python/bin/python3" ]]; then
672
+ daemon_python="$CURRENT_LINK/python/bin/python3"
673
+ fi
562
674
  python3 "$REPO_ROOT/mac/install/render-launchd.py" \
563
675
  --current-root "$CURRENT_LINK" \
564
676
  --logs-root "$LOGS_ROOT" \
565
677
  --output-dir "$PLIST_BUILD_DIR" \
566
- --daemon-python "$PYTHON3_BIN" \
678
+ --daemon-python "$daemon_python" \
567
679
  --guardian-python "$GUARDIAN_PYTHON_BIN"
568
680
  }
569
681
 
570
682
  unload_legacy_daemon() {
571
683
  if is_dry_run; then
572
- log "dry-run: would unload $LEGACY_DAEMON_LABEL"
684
+ log "dry-run: would check legacy predecessor cleanup"
573
685
  return
574
686
  fi
575
- launchctl bootout "gui/$(id -u)/$LEGACY_DAEMON_LABEL" >/dev/null 2>&1 || true
576
- launchctl bootout "gui/$(id -u)" "$LEGACY_USER_PLIST" >/dev/null 2>&1 || true
687
+ return 0
577
688
  }
578
689
 
579
690
  start_user_agent() {
@@ -602,6 +713,259 @@ start_connectd_agent() {
602
713
  launchctl kickstart -k "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL"
603
714
  }
604
715
 
716
+ ptybroker_live_session_count() {
717
+ local status_json
718
+ if status_json="$(ptybroker_status_json 2>/dev/null)"; then
719
+ python3 - "$status_json" <<'PY'
720
+ import json
721
+ import sys
722
+
723
+ payload = json.loads(sys.argv[1])
724
+ status = payload.get("status") if isinstance(payload.get("status"), dict) else {}
725
+ print(status.get("live_session_count", "unknown"))
726
+ PY
727
+ else
728
+ printf '%s\n' "unknown"
729
+ fi
730
+ }
731
+
732
+ ptybroker_status_json() {
733
+ "$PYTHON3_BIN" - "$CURRENT_LINK" <<'PY'
734
+ import json
735
+ import sys
736
+ from pathlib import Path
737
+
738
+ current = Path(sys.argv[1])
739
+ sys.path.insert(0, str(current / "companiond"))
740
+ from pty_broker_client import PTYBrokerClient, ensure_pty_broker_token
741
+
742
+ companion = Path.home() / ".claude" / "companion"
743
+ client = PTYBrokerClient(companion / "pty-broker.sock", ensure_pty_broker_token(companion), timeout=1.0)
744
+ print(json.dumps({"ok": True, "status": client.status()}, sort_keys=True))
745
+ PY
746
+ }
747
+
748
+ ptybroker_desired_revision() {
749
+ python3 - "$CURRENT_LINK" <<'PY'
750
+ import json
751
+ import sys
752
+ from pathlib import Path
753
+
754
+ current = Path(sys.argv[1])
755
+ for path in [current / "manifest.json", current / "mac" / "SOURCE_REVISION", current / "SOURCE_REVISION"]:
756
+ try:
757
+ if path.name == "manifest.json":
758
+ print(json.loads(path.read_text()).get("source_revision") or "")
759
+ else:
760
+ print(path.read_text().strip())
761
+ raise SystemExit(0)
762
+ except FileNotFoundError:
763
+ continue
764
+ except Exception:
765
+ continue
766
+ print("")
767
+ PY
768
+ }
769
+
770
+ ptybroker_live_revision() {
771
+ python3 - "${1:-{}}" <<'PY'
772
+ import json
773
+ import sys
774
+
775
+ payload = json.loads(sys.argv[1])
776
+ status = payload.get("status") if isinstance(payload.get("status"), dict) else payload
777
+ print(status.get("source_revision") or "")
778
+ PY
779
+ }
780
+
781
+ ptybroker_deployment_state_json() {
782
+ python3 - "$CURRENT_LINK" "${1:-{}}" <<'PY'
783
+ import json
784
+ import os
785
+ import sys
786
+ from pathlib import Path
787
+
788
+ current = Path(sys.argv[1])
789
+ payload = json.loads(sys.argv[2])
790
+ live = payload.get("status") if isinstance(payload.get("status"), dict) else payload
791
+
792
+ def read_revision(root: Path):
793
+ for path in [root / "manifest.json", root / "mac" / "SOURCE_REVISION", root / "SOURCE_REVISION"]:
794
+ try:
795
+ if path.name == "manifest.json":
796
+ return json.loads(path.read_text()).get("source_revision")
797
+ value = path.read_text().strip()
798
+ return value or None
799
+ except FileNotFoundError:
800
+ continue
801
+ except Exception:
802
+ continue
803
+ return None
804
+
805
+ desired_root = current.resolve()
806
+ desired = {
807
+ "runtime_root": str(desired_root),
808
+ "script_path": str(desired_root / "companiond" / "pty_broker_service.py"),
809
+ "source_revision": read_revision(desired_root),
810
+ "protocol_version": 1,
811
+ }
812
+ reasons = []
813
+ live_root = live.get("runtime_root")
814
+ if live_root:
815
+ if os.path.realpath(str(live_root)) != str(desired_root):
816
+ reasons.append("runtime_root_mismatch")
817
+ else:
818
+ reasons.append("runtime_root_missing")
819
+ live_script = live.get("script_path")
820
+ if live_script:
821
+ if os.path.realpath(str(live_script)) != str(desired["script_path"]):
822
+ reasons.append("script_path_mismatch")
823
+ else:
824
+ reasons.append("script_path_missing")
825
+ live_revision = live.get("source_revision")
826
+ if desired["source_revision"] and not live_revision:
827
+ reasons.append("source_revision_missing")
828
+ elif live_revision and desired["source_revision"] and str(live_revision) != str(desired["source_revision"]):
829
+ reasons.append("source_revision_mismatch")
830
+ try:
831
+ live_protocol = int(live.get("protocol_version") or 0)
832
+ except (TypeError, ValueError):
833
+ live_protocol = 0
834
+ if live_protocol != desired["protocol_version"]:
835
+ if not live.get("protocol_version"):
836
+ reasons.append("protocol_version_missing")
837
+ else:
838
+ reasons.append("protocol_version_mismatch")
839
+ state = "current" if not reasons else "stale_deferred"
840
+ print(json.dumps({
841
+ "state": state,
842
+ "restart_deferred": state == "stale_deferred",
843
+ "reasons": reasons,
844
+ "desired": desired,
845
+ "live": live,
846
+ }, sort_keys=True))
847
+ PY
848
+ }
849
+
850
+ ptybroker_report_deferred_restart() {
851
+ local state_json
852
+ state_json="$(ptybroker_deployment_state_json "$1")"
853
+ python3 - "$state_json" <<'PY'
854
+ import json
855
+ import sys
856
+
857
+ state = json.loads(sys.argv[1])
858
+ if state.get("state") != "stale_deferred":
859
+ raise SystemExit(0)
860
+ live = state.get("live") if isinstance(state.get("live"), dict) else {}
861
+ desired = state.get("desired") if isinstance(state.get("desired"), dict) else {}
862
+ print(
863
+ "WARNING: ptybroker running older code; normal install preserved live PTYs; "
864
+ "broker restart is deferred; "
865
+ f"live_source_revision={live.get('source_revision')} "
866
+ f"desired_source_revision={desired.get('source_revision')} "
867
+ f"live_pid={live.get('pid')} "
868
+ f"live_session_count={live.get('live_session_count')}"
869
+ )
870
+ PY
871
+ }
872
+
873
+ ptybroker_state_field() {
874
+ python3 - "$1" "$2" <<'PY'
875
+ import json
876
+ import sys
877
+
878
+ payload = json.loads(sys.argv[1])
879
+ value = payload
880
+ for part in sys.argv[2].split("."):
881
+ if isinstance(value, dict):
882
+ value = value.get(part)
883
+ else:
884
+ value = None
885
+ print("" if value is None else value)
886
+ PY
887
+ }
888
+
889
+ ensure_ptybroker_agent() {
890
+ mkdir -p "$HOME/Library/LaunchAgents"
891
+ local rendered="$PLIST_BUILD_DIR/$PAIRLING_PTYBROKER_LABEL.plist"
892
+ local changed=0
893
+ if [[ ! -f "$PTYBROKER_USER_PLIST" ]] || ! cmp -s "$rendered" "$PTYBROKER_USER_PLIST"; then
894
+ cp "$rendered" "$PTYBROKER_USER_PLIST"
895
+ chmod 644 "$PTYBROKER_USER_PLIST"
896
+ changed=1
897
+ fi
898
+ if is_dry_run; then
899
+ if [[ "$changed" == "1" ]]; then
900
+ log "dry-run: rendered $PTYBROKER_USER_PLIST"
901
+ else
902
+ log "dry-run: $PTYBROKER_USER_PLIST unchanged"
903
+ fi
904
+ return
905
+ fi
906
+ if ! launchctl print "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1; then
907
+ launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
908
+ launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
909
+ return
910
+ fi
911
+ local status_json
912
+ if status_json="$(ptybroker_status_json 2>/dev/null)"; then
913
+ ptybroker_report_deferred_restart "$status_json"
914
+ else
915
+ log "WARNING: ptybroker status unreachable_socket; normal install preserved live PTYs but broker freshness is unknown; broker restart is deferred"
916
+ fi
917
+ if [[ "$changed" == "1" ]]; then
918
+ local live_count
919
+ live_count="$(ptybroker_live_session_count)"
920
+ log "ptybroker plist changed but broker is already loaded; preserving PTYs and deferring broker restart (live_sessions=$live_count)"
921
+ fi
922
+ if [[ ! -S "$HOME/.claude/companion/pty-broker.sock" ]]; then
923
+ launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
924
+ fi
925
+ }
926
+
927
+ reconcile_ptybroker() {
928
+ ensure_state
929
+ render_plists
930
+ mkdir -p "$HOME/Library/LaunchAgents"
931
+ cp "$PLIST_BUILD_DIR/$PAIRLING_PTYBROKER_LABEL.plist" "$PTYBROKER_USER_PLIST"
932
+ chmod 644 "$PTYBROKER_USER_PLIST"
933
+ if is_dry_run; then
934
+ log "dry-run: would reconcile $PAIRLING_PTYBROKER_LABEL"
935
+ return
936
+ fi
937
+ if ! launchctl print "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1; then
938
+ launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
939
+ launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
940
+ log "Started $PAIRLING_PTYBROKER_LABEL"
941
+ return
942
+ fi
943
+ local status_json state_json live_count live_pid
944
+ if ! status_json="$(ptybroker_status_json 2>/dev/null)"; then
945
+ log "ERROR: ptybroker is loaded but status RPC is unreachable; refusing reconcile until socket is reachable or broker is manually stopped." >&2
946
+ exit 1
947
+ fi
948
+ state_json="$(ptybroker_deployment_state_json "$status_json")"
949
+ live_count="$(ptybroker_state_field "$state_json" "live.live_session_count")"
950
+ live_pid="$(ptybroker_state_field "$state_json" "live.pid")"
951
+ if [[ "${live_count:-0}" != "0" ]]; then
952
+ log "ERROR: ptybroker restart deferred: live_session_count=$live_count live_pid=$live_pid; close/drain live PTYs before broker code can be updated." >&2
953
+ exit 1
954
+ fi
955
+ log "Operator requested idle ptybroker reconcile; restarting broker live_pid=$live_pid live_session_count=0"
956
+ launchctl bootout "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
957
+ launchctl bootout "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
958
+ launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
959
+ launchctl kickstart -k "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL"
960
+ status_json="$(ptybroker_status_json)"
961
+ state_json="$(ptybroker_deployment_state_json "$status_json")"
962
+ if [[ "$(ptybroker_state_field "$state_json" "state")" != "current" ]]; then
963
+ log "ERROR: ptybroker restart completed but status is not current: $state_json" >&2
964
+ exit 1
965
+ fi
966
+ log "Reconciled $PAIRLING_PTYBROKER_LABEL with current runtime"
967
+ }
968
+
605
969
  stop_user_agent() {
606
970
  if is_dry_run; then
607
971
  log "dry-run: would stop $PAIRLING_DAEMON_LABEL"
@@ -623,7 +987,7 @@ stop_connectd_agent() {
623
987
  install_guardian_if_possible() {
624
988
  local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
625
989
  if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
626
- log "Guardian LaunchDaemon rendered but not installed. Set PAIRLING_INSTALL_GUARDIAN=1 to install it."
990
+ log "Optional power guardian not installed; pairing can continue without the privileged sleep helper."
627
991
  return
628
992
  fi
629
993
  if is_dry_run; then
@@ -643,7 +1007,7 @@ install_guardian_if_possible() {
643
1007
  }
644
1008
 
645
1009
  run_doctor() {
646
- "$REPO_ROOT/mac/install/doctor.sh" --json
1010
+ "$REPO_ROOT/mac/install/doctor.sh"
647
1011
  }
648
1012
 
649
1013
  rollback() {
@@ -661,6 +1025,7 @@ rollback() {
661
1025
  ln -s "$current_target" "$PREVIOUS_LINK"
662
1026
  fi
663
1027
  render_plists
1028
+ ensure_ptybroker_agent
664
1029
  start_user_agent
665
1030
  start_connectd_agent
666
1031
  append_history "rollback" "rolled back to $previous_target"
@@ -669,13 +1034,14 @@ rollback() {
669
1034
 
670
1035
  install_runtime() {
671
1036
  log "Pairling setup preview:"
672
- log " app support: $APP_SUPPORT"
673
- log " logs: $LOGS_ROOT"
1037
+ log " app support: $(display_path "$APP_SUPPORT")"
1038
+ log " logs: $(display_path "$LOGS_ROOT")"
674
1039
  log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
1040
+ log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
675
1041
  log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
676
1042
  log " runtime port: $PAIRLING_RUNTIME_PORT"
677
- log " old Pairling predecessor cleanup label: $LEGACY_DAEMON_LABEL"
678
1043
  run_compile_checks
1044
+ run_psk_dependency_checks
679
1045
  ensure_state
680
1046
  copy_release
681
1047
  switch_current
@@ -683,6 +1049,7 @@ install_runtime() {
683
1049
  install_shell_wrapper
684
1050
  render_plists
685
1051
  unload_legacy_daemon
1052
+ ensure_ptybroker_agent
686
1053
  start_user_agent
687
1054
  start_connectd_agent
688
1055
  install_guardian_if_possible
@@ -703,6 +1070,7 @@ start_runtime() {
703
1070
  ensure_state
704
1071
  render_plists
705
1072
  unload_legacy_daemon
1073
+ ensure_ptybroker_agent
706
1074
  start_user_agent
707
1075
  start_connectd_agent
708
1076
  log "Started $PAIRLING_DAEMON_LABEL"
@@ -811,6 +1179,11 @@ secret = str(
811
1179
  )
812
1180
  install_id = str(payload.get("install_id") or "")
813
1181
  mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_name") or socket.gethostname())
1182
+ # WS3: the Mac ephemeral ECDH public key (base64url) from /pair/start. Carrying it in the
1183
+ # pair URL is what lets the phone run PSK-authenticated ECDH from the OUT-OF-BAND (QR/paste)
1184
+ # payload — the secret never goes on the wire. Without it the phone falls back to the legacy
1185
+ # plaintext claim, so this field is the bridge that actually makes WS3 engage.
1186
+ mac_ake_pub = str(payload.get("mac_ake_pub") or (payload.get("claim") or {}).get("mac_ake_pub") or "")
814
1187
 
815
1188
  def detected_tailnet_ip() -> str:
816
1189
  override = os.environ.get("PAIRLING_TEST_TAILSCALE_IP")
@@ -867,6 +1240,12 @@ if pair_id and secret:
867
1240
  "pair_id": pair_id,
868
1241
  "secret": secret,
869
1242
  }
1243
+ if mac_ake_pub:
1244
+ # WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
1245
+ # to PSK-authenticated ECDH (secret never transmitted) when both are present; their
1246
+ # absence is the legacy plaintext claim. pv=2 == PSK protocol version.
1247
+ pair_params["mac_ake_pub"] = mac_ake_pub
1248
+ pair_params["pv"] = "2"
870
1249
  if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
871
1250
  pair_params["route_source"] = "pairling_connectd"
872
1251
  pair_params["route_status"] = "ready"
@@ -1162,6 +1541,7 @@ commands:
1162
1541
  status
1163
1542
  doctor --json
1164
1543
  doctor --first-run --json
1544
+ reconcile-ptybroker
1165
1545
  pair
1166
1546
  connect-auth-open
1167
1547
  devices
@@ -1204,6 +1584,9 @@ case "$cmd" in
1204
1584
  doctor)
1205
1585
  "$REPO_ROOT/mac/install/doctor.sh" "$@"
1206
1586
  ;;
1587
+ reconcile-ptybroker|--reconcile-ptybroker|--restart-ptybroker-if-idle)
1588
+ reconcile_ptybroker
1589
+ ;;
1207
1590
  pair)
1208
1591
  pair_runtime "$@"
1209
1592
  ;;
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def main() -> int:
11
+ parser = argparse.ArgumentParser(description="Verify Pairling PSK pairing import dependencies.")
12
+ parser.add_argument("companiond_path")
13
+ parser.add_argument("--label", default="dependency check")
14
+ args = parser.parse_args()
15
+
16
+ companiond = Path(args.companiond_path).resolve()
17
+ sys.path.insert(0, str(companiond))
18
+ os.environ.pop("PAIRLING_PSK_REQUIRED", None)
19
+
20
+ try:
21
+ import cryptography # noqa: F401
22
+ import pairling_psk # noqa: F401
23
+ import pairling_pairing # noqa: F401
24
+ except Exception as exc:
25
+ print(
26
+ "PSK pairing dependency check failed during "
27
+ f"{args.label}: companiond_path={companiond}; Python must import "
28
+ "cryptography, pairling_psk, and pairling_pairing while "
29
+ "PAIRLING_PSK_REQUIRED is default-on; otherwise PSK pairing is "
30
+ "unavailable/fail-closed and daemon liveness alone is insufficient "
31
+ "(pairing endpoints may return pairing_unavailable). "
32
+ f"Cause: {type(exc).__name__}: {exc}",
33
+ file=sys.stderr,
34
+ )
35
+ return 1
36
+ return 0
37
+
38
+
39
+ if __name__ == "__main__":
40
+ raise SystemExit(main())