pairling 0.2.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 +160 -18
  25. package/payload/mac/install/install-runtime.sh +329 -12
  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
@@ -50,7 +50,7 @@ PAIRLING_RUNTIME_PORT="${PAIRLING_RUNTIME_PORT:-7773}"
50
50
  PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
51
51
  PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
52
52
  PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
53
- LEGACY_DAEMON_LABEL="com.mghome.notify-webhook"
53
+ PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
54
54
  APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
55
55
  RUNTIME_ROOT="$APP_SUPPORT/runtime"
56
56
  RELEASES_ROOT="$RUNTIME_ROOT/releases"
@@ -68,9 +68,8 @@ MCP_CREDENTIAL="$APP_SUPPORT/mcp-bridge.json"
68
68
  INSTALL_HISTORY="$STATE_ROOT/install-history.jsonl"
69
69
  USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
70
70
  CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
71
- LEGACY_USER_PLIST="$HOME/Library/LaunchAgents/$LEGACY_DAEMON_LABEL.plist"
71
+ PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
72
72
  SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
73
- LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
74
73
  MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
75
74
  MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
76
75
  PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
@@ -88,6 +87,15 @@ log() {
88
87
  printf '%s\n' "$*"
89
88
  }
90
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
+
91
99
  is_dry_run() {
92
100
  [[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
93
101
  }
@@ -128,9 +136,13 @@ run_compile_checks() {
128
136
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/llm_route.py"
129
137
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_tools.py"
130
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"
131
140
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_relay_claims.py"
132
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"
133
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"
134
146
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_screen_backend.py"
135
147
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py"
136
148
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/push_dispatcher.py"
@@ -156,9 +168,30 @@ run_compile_checks() {
156
168
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/companion-power-guardian.py"
157
169
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/guardian_contract.py"
158
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"
159
172
  rm -rf "$pycache_root"
160
173
  }
161
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
+
162
195
  ensure_state() {
163
196
  mkdir -p "$RELEASES_ROOT" "$STATE_ROOT" "$PAIR_ROOT" "$LOGS_ROOT" "$PLIST_BUILD_DIR" "$APP_SUPPORT/modules"
164
197
  chmod 700 "$APP_SUPPORT" "$PAIR_ROOT" 2>/dev/null || true
@@ -248,9 +281,13 @@ copy_release() {
248
281
  cp "$REPO_ROOT/mac/companiond/llm_route.py" "$tmp/companiond/"
249
282
  cp "$REPO_ROOT/mac/companiond/pairling_tools.py" "$tmp/companiond/"
250
283
  cp "$REPO_ROOT/mac/companiond/pairling_pairing.py" "$tmp/companiond/"
284
+ cp "$REPO_ROOT/mac/companiond/pairling_psk.py" "$tmp/companiond/"
251
285
  cp "$REPO_ROOT/mac/companiond/pairling_relay_claims.py" "$tmp/companiond/"
252
286
  cp "$REPO_ROOT/mac/companiond/request_proof.py" "$tmp/companiond/"
287
+ cp "$REPO_ROOT/mac/companiond/codex_approval.py" "$tmp/companiond/"
253
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/"
254
291
  cp "$REPO_ROOT/mac/companiond/terminal_screen_backend.py" "$tmp/companiond/"
255
292
  cp "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py" "$tmp/companiond/"
256
293
  cp "$REPO_ROOT/mac/companiond/push_dispatcher.py" "$tmp/companiond/"
@@ -270,6 +307,7 @@ copy_release() {
270
307
  cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
271
308
  build_connectd_binary "$tmp/connectd/pairling-connectd"
272
309
  stage_vendored_python "$tmp/python"
310
+ run_staged_psk_dependency_checks "$tmp"
273
311
  copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
274
312
  write_installed_pairling_launcher "$tmp/bin/pairling"
275
313
  chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
@@ -301,6 +339,10 @@ copy_runtime_source_tree() {
301
339
  printf '%s\n' "$BRANCH" > "$mac_root/SOURCE_BRANCH"
302
340
  printf '%s\n' "$SOURCE_DIRTY" > "$mac_root/SOURCE_DIRTY"
303
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
304
346
  cp "$REPO_ROOT/mac/companiond/providers/"*.py "$mac_root/companiond/providers/"
305
347
  cp "$REPO_ROOT/mac/companiond/integrations/__init__.py" "$mac_root/companiond/integrations/"
306
348
  cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$mac_root/companiond/integrations/aperture_cli/"
@@ -464,9 +506,13 @@ for rel in [
464
506
  "companiond/llm_route.py",
465
507
  "companiond/pairling_tools.py",
466
508
  "companiond/pairling_pairing.py",
509
+ "companiond/pairling_psk.py",
467
510
  "companiond/pairling_relay_claims.py",
468
511
  "companiond/request_proof.py",
512
+ "companiond/codex_approval.py",
469
513
  "companiond/pty_broker.py",
514
+ "companiond/pty_broker_client.py",
515
+ "companiond/pty_broker_service.py",
470
516
  "companiond/terminal_screen_backend.py",
471
517
  "companiond/terminal_text_sanitizer.py",
472
518
  "companiond/push_dispatcher.py",
@@ -519,9 +565,9 @@ manifest = {
519
565
  },
520
566
  "launchd": {
521
567
  "daemon_label": "dev.pairling.companiond",
568
+ "ptybroker_label": "dev.pairling.ptybroker",
522
569
  "connectd_label": "dev.pairling.connectd",
523
570
  "guardian_label": "dev.pairling.power-guardian",
524
- "legacy_daemon_label": "com.mghome.notify-webhook",
525
571
  },
526
572
  "paths": {
527
573
  "app_support": app_support,
@@ -635,11 +681,10 @@ render_plists() {
635
681
 
636
682
  unload_legacy_daemon() {
637
683
  if is_dry_run; then
638
- log "dry-run: would unload $LEGACY_DAEMON_LABEL"
684
+ log "dry-run: would check legacy predecessor cleanup"
639
685
  return
640
686
  fi
641
- launchctl bootout "gui/$(id -u)/$LEGACY_DAEMON_LABEL" >/dev/null 2>&1 || true
642
- launchctl bootout "gui/$(id -u)" "$LEGACY_USER_PLIST" >/dev/null 2>&1 || true
687
+ return 0
643
688
  }
644
689
 
645
690
  start_user_agent() {
@@ -668,6 +713,259 @@ start_connectd_agent() {
668
713
  launchctl kickstart -k "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL"
669
714
  }
670
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
+
671
969
  stop_user_agent() {
672
970
  if is_dry_run; then
673
971
  log "dry-run: would stop $PAIRLING_DAEMON_LABEL"
@@ -689,7 +987,7 @@ stop_connectd_agent() {
689
987
  install_guardian_if_possible() {
690
988
  local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
691
989
  if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
692
- 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."
693
991
  return
694
992
  fi
695
993
  if is_dry_run; then
@@ -709,7 +1007,7 @@ install_guardian_if_possible() {
709
1007
  }
710
1008
 
711
1009
  run_doctor() {
712
- "$REPO_ROOT/mac/install/doctor.sh" --json
1010
+ "$REPO_ROOT/mac/install/doctor.sh"
713
1011
  }
714
1012
 
715
1013
  rollback() {
@@ -727,6 +1025,7 @@ rollback() {
727
1025
  ln -s "$current_target" "$PREVIOUS_LINK"
728
1026
  fi
729
1027
  render_plists
1028
+ ensure_ptybroker_agent
730
1029
  start_user_agent
731
1030
  start_connectd_agent
732
1031
  append_history "rollback" "rolled back to $previous_target"
@@ -735,13 +1034,14 @@ rollback() {
735
1034
 
736
1035
  install_runtime() {
737
1036
  log "Pairling setup preview:"
738
- log " app support: $APP_SUPPORT"
739
- log " logs: $LOGS_ROOT"
1037
+ log " app support: $(display_path "$APP_SUPPORT")"
1038
+ log " logs: $(display_path "$LOGS_ROOT")"
740
1039
  log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
1040
+ log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
741
1041
  log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
742
1042
  log " runtime port: $PAIRLING_RUNTIME_PORT"
743
- log " old Pairling predecessor cleanup label: $LEGACY_DAEMON_LABEL"
744
1043
  run_compile_checks
1044
+ run_psk_dependency_checks
745
1045
  ensure_state
746
1046
  copy_release
747
1047
  switch_current
@@ -749,6 +1049,7 @@ install_runtime() {
749
1049
  install_shell_wrapper
750
1050
  render_plists
751
1051
  unload_legacy_daemon
1052
+ ensure_ptybroker_agent
752
1053
  start_user_agent
753
1054
  start_connectd_agent
754
1055
  install_guardian_if_possible
@@ -769,6 +1070,7 @@ start_runtime() {
769
1070
  ensure_state
770
1071
  render_plists
771
1072
  unload_legacy_daemon
1073
+ ensure_ptybroker_agent
772
1074
  start_user_agent
773
1075
  start_connectd_agent
774
1076
  log "Started $PAIRLING_DAEMON_LABEL"
@@ -877,6 +1179,11 @@ secret = str(
877
1179
  )
878
1180
  install_id = str(payload.get("install_id") or "")
879
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 "")
880
1187
 
881
1188
  def detected_tailnet_ip() -> str:
882
1189
  override = os.environ.get("PAIRLING_TEST_TAILSCALE_IP")
@@ -933,6 +1240,12 @@ if pair_id and secret:
933
1240
  "pair_id": pair_id,
934
1241
  "secret": secret,
935
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"
936
1249
  if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
937
1250
  pair_params["route_source"] = "pairling_connectd"
938
1251
  pair_params["route_status"] = "ready"
@@ -1228,6 +1541,7 @@ commands:
1228
1541
  status
1229
1542
  doctor --json
1230
1543
  doctor --first-run --json
1544
+ reconcile-ptybroker
1231
1545
  pair
1232
1546
  connect-auth-open
1233
1547
  devices
@@ -1270,6 +1584,9 @@ case "$cmd" in
1270
1584
  doctor)
1271
1585
  "$REPO_ROOT/mac/install/doctor.sh" "$@"
1272
1586
  ;;
1587
+ reconcile-ptybroker|--reconcile-ptybroker|--restart-ptybroker-if-idle)
1588
+ reconcile_ptybroker
1589
+ ;;
1273
1590
  pair)
1274
1591
  pair_runtime "$@"
1275
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())
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  PAIRLING_DAEMON_LABEL = "dev.pairling.companiond"
11
11
  PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
12
12
  PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
13
+ PAIRLING_PTYBROKER_LABEL = "dev.pairling.ptybroker"
13
14
  PAIRLING_RUNTIME_PORT = "7773"
14
15
 
15
16
 
@@ -95,6 +96,27 @@ def connectd_plist(current: Path, logs: Path) -> dict:
95
96
  }
96
97
 
97
98
 
99
+ def ptybroker_plist(current: Path, logs: Path, python_bin: str) -> dict:
100
+ app_support = current.parent.parent
101
+ return {
102
+ "Label": PAIRLING_PTYBROKER_LABEL,
103
+ "ProgramArguments": [
104
+ python_bin,
105
+ str(current / "companiond" / "pty_broker_service.py"),
106
+ ],
107
+ "EnvironmentVariables": {
108
+ "PAIRLING_APP_SUPPORT_ROOT": str(app_support),
109
+ "PAIRLING_LOGS_ROOT": str(logs),
110
+ "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
111
+ },
112
+ "RunAtLoad": True,
113
+ "KeepAlive": True,
114
+ "ThrottleInterval": 10,
115
+ "StandardOutPath": str(logs / "ptybroker.log"),
116
+ "StandardErrorPath": str(logs / "ptybroker.err"),
117
+ }
118
+
119
+
98
120
  def main() -> int:
99
121
  parser = argparse.ArgumentParser()
100
122
  parser.add_argument("--current-root", required=True)
@@ -110,6 +132,7 @@ def main() -> int:
110
132
  out = Path(args.output_dir)
111
133
 
112
134
  write_plist(out / f"{PAIRLING_DAEMON_LABEL}.plist", daemon_plist(current, logs, args.daemon_python))
135
+ write_plist(out / f"{PAIRLING_PTYBROKER_LABEL}.plist", ptybroker_plist(current, logs, args.daemon_python))
113
136
  write_plist(out / f"{PAIRLING_GUARDIAN_LABEL}.plist", guardian_plist(current, logs, args.guardian_python))
114
137
  write_plist(out / f"{PAIRLING_CONNECTD_LABEL}.plist", connectd_plist(current, logs))
115
138
  return 0
@@ -4,14 +4,13 @@ set -euo pipefail
4
4
  PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
5
5
  PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
6
6
  PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
7
- LEGACY_DAEMON_LABEL="com.mghome.notify-webhook"
7
+ PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
8
8
  APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
9
9
  LOGS_ROOT="${PAIRLING_LOGS_ROOT:-${COMPANION_LOGS_ROOT:-$HOME/Library/Logs/Pairling}}"
10
10
  USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
11
11
  CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
12
- LEGACY_USER_PLIST="$HOME/Library/LaunchAgents/$LEGACY_DAEMON_LABEL.plist"
12
+ PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
13
13
  SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
14
- LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
15
14
  YES="false"
16
15
  DELETE_STATE="false"
17
16
  DELETE_LOGS="false"
@@ -103,19 +102,12 @@ confirm
103
102
 
104
103
  bootout_user "$PAIRLING_DAEMON_LABEL" "$USER_PLIST"
105
104
  bootout_user "$PAIRLING_CONNECTD_LABEL" "$CONNECTD_USER_PLIST"
105
+ bootout_user "$PAIRLING_PTYBROKER_LABEL" "$PTYBROKER_USER_PLIST"
106
106
  rm -f "$USER_PLIST"
107
107
  rm -f "$CONNECTD_USER_PLIST"
108
+ rm -f "$PTYBROKER_USER_PLIST"
108
109
  bootout_system "$PAIRLING_GUARDIAN_LABEL" "$SYSTEM_PLIST"
109
110
 
110
- if [[ -f "$LEGACY_USER_PLIST" ]]; then
111
- bootout_user "$LEGACY_DAEMON_LABEL" "$LEGACY_USER_PLIST"
112
- printf 'Warning: legacy LaunchAgent still exists and was unloaded if possible: %s\n' "$LEGACY_USER_PLIST"
113
- fi
114
-
115
- if [[ -f "$LEGACY_SYSTEM_PLIST" ]]; then
116
- printf 'Warning: legacy guardian LaunchDaemon still exists: %s\n' "$LEGACY_SYSTEM_PLIST"
117
- fi
118
-
119
111
  rm -rf "$APP_SUPPORT/pair" 2>/dev/null || true
120
112
 
121
113
  if [[ "$DELETE_STATE" == "true" ]]; then