pairling 0.2.4 → 0.2.6

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 +11 -9
  2. package/bin/pairling.mjs +5 -2
  3. package/package.json +7 -7
  4. package/payload/mac/SOURCE_BRANCH +1 -1
  5. package/payload/mac/SOURCE_REVISION +1 -1
  6. package/payload/mac/VERSION +1 -1
  7. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +67 -20
  10. package/payload/mac/companiond/pairlingd.py +269 -16
  11. package/payload/mac/companiond/push_dispatcher.py +31 -1
  12. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  13. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  14. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  17. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  18. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  20. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  21. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  22. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  23. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  24. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  25. package/payload/mac/connectd/internal/status/status.go +67 -1
  26. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  27. package/payload/mac/install/install-runtime.sh +421 -41
  28. package/payload/mac/install/render-launchd.py +54 -10
  29. package/payload-manifest.json +63 -21
@@ -51,6 +51,7 @@ PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
51
51
  PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
52
52
  PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
53
53
  PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
54
+ PAIRLING_MINTD_LABEL="dev.pairling.mintd"
54
55
  APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
55
56
  RUNTIME_ROOT="$APP_SUPPORT/runtime"
56
57
  RELEASES_ROOT="$RUNTIME_ROOT/releases"
@@ -70,6 +71,12 @@ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
70
71
  CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
71
72
  PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
72
73
  SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
74
+ MINTD_SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_MINTD_LABEL.plist"
75
+ MINTD_SYSTEM_ROOT="/Library/Application Support/Pairling"
76
+ MINTD_SECRET_DIR="$MINTD_SYSTEM_ROOT/mint"
77
+ MINTD_RUN_DIR="$MINTD_SYSTEM_ROOT/run/mintd"
78
+ MINTD_SYSTEM_BINARY="$MINTD_SECRET_DIR/pairling-tailnet-mintd"
79
+ MINTD_LOGS_DIR="/Library/Logs/Pairling"
73
80
  MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
74
81
  MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
75
82
  PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
@@ -306,12 +313,13 @@ copy_release() {
306
313
  cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
307
314
  cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
308
315
  build_connectd_binary "$tmp/connectd/pairling-connectd"
316
+ build_mintd_binary "$tmp/connectd/pairling-tailnet-mintd"
309
317
  stage_vendored_python "$tmp/python"
310
318
  run_staged_psk_dependency_checks "$tmp"
311
- copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
319
+ copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd" "$tmp/connectd/pairling-tailnet-mintd"
312
320
  write_installed_pairling_launcher "$tmp/bin/pairling"
313
321
  chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
314
- chmod 755 "$tmp/connectd/pairling-connectd"
322
+ chmod 755 "$tmp/connectd/pairling-connectd" "$tmp/connectd/pairling-tailnet-mintd"
315
323
  chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py "$tmp/guardian/"*.py
316
324
  chmod 644 "$tmp/companiond/providers/"*.py
317
325
  chmod 644 "$tmp/companiond/integrations/"*.py "$tmp/companiond/integrations/aperture_cli/"*.py
@@ -325,6 +333,7 @@ copy_release() {
325
333
  copy_runtime_source_tree() {
326
334
  local mac_root="$1"
327
335
  local connectd_binary="$2"
336
+ local mintd_binary="$3"
328
337
  mkdir -p \
329
338
  "$mac_root/companiond" \
330
339
  "$mac_root/companiond/providers" \
@@ -351,12 +360,13 @@ copy_runtime_source_tree() {
351
360
  cp -R "$REPO_ROOT/mac/connectd/cmd" "$mac_root/connectd/"
352
361
  cp -R "$REPO_ROOT/mac/connectd/internal" "$mac_root/connectd/"
353
362
  cp "$connectd_binary" "$mac_root/connectd/bin/pairling-connectd"
363
+ cp "$mintd_binary" "$mac_root/connectd/bin/pairling-tailnet-mintd"
354
364
  cp "$REPO_ROOT/mac/guardian/"*.py "$mac_root/guardian/"
355
365
  cp "$REPO_ROOT/mac/install/"*.sh "$mac_root/install/"
356
366
  cp "$REPO_ROOT/mac/install/"*.py "$mac_root/install/"
357
367
  cp "$REPO_ROOT/mac/mcp/"*.py "$mac_root/mcp/"
358
368
  cp "$REPO_ROOT/mac/packaging/bin/pairling" "$mac_root/packaging/bin/"
359
- chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
369
+ chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/connectd/bin/pairling-tailnet-mintd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
360
370
  chmod 644 "$mac_root/VERSION" "$mac_root/SOURCE_REVISION" "$mac_root/SOURCE_BRANCH" "$mac_root/SOURCE_DIRTY"
361
371
  }
362
372
 
@@ -480,6 +490,57 @@ build_connectd_binary() {
480
490
  )
481
491
  }
482
492
 
493
+ build_mintd_binary() {
494
+ local out="$1"
495
+ local prebuilt_env="${PAIRLING_MINTD_PREBUILT:-}"
496
+ if [[ -n "$prebuilt_env" ]]; then
497
+ if [[ ! -f "$prebuilt_env" ]]; then
498
+ log "ERROR: PAIRLING_MINTD_PREBUILT points at a missing file: $prebuilt_env" >&2
499
+ exit 1
500
+ fi
501
+ local required_team="${PAIRLING_MINTD_TEAM_ID:-${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}}"
502
+ if [[ "$required_team" != "-" ]]; then
503
+ if ! /usr/bin/codesign --verify --strict "$prebuilt_env" >/dev/null 2>&1; then
504
+ log "ERROR: mintd binary failed codesign verification; refusing to stage: $prebuilt_env" >&2
505
+ exit 1
506
+ fi
507
+ local team
508
+ team="$(/usr/bin/codesign -dvv "$prebuilt_env" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
509
+ if [[ "$team" != "$required_team" ]]; then
510
+ log "ERROR: mintd binary TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage: $prebuilt_env" >&2
511
+ exit 1
512
+ fi
513
+ fi
514
+ cp "$prebuilt_env" "$out"
515
+ chmod 755 "$out"
516
+ return
517
+ fi
518
+ local prebuilt="$REPO_ROOT/mac/connectd/bin/pairling-tailnet-mintd"
519
+ if [[ -x "$prebuilt" ]]; then
520
+ cp "$prebuilt" "$out"
521
+ chmod 755 "$out"
522
+ return
523
+ fi
524
+ local go_bin
525
+ go_bin="$(command -v go || true)"
526
+ if [[ -z "$go_bin" ]]; then
527
+ for candidate in /opt/homebrew/bin/go /usr/local/go/bin/go /usr/local/bin/go; do
528
+ if [[ -x "$candidate" ]]; then
529
+ go_bin="$candidate"
530
+ break
531
+ fi
532
+ done
533
+ fi
534
+ if [[ -z "$go_bin" ]]; then
535
+ log "ERROR: go is required to build pairling-tailnet-mintd" >&2
536
+ exit 1
537
+ fi
538
+ (
539
+ cd "$REPO_ROOT/mac/connectd"
540
+ "$go_bin" build -o "$out" ./cmd/pairling-tailnet-mintd
541
+ )
542
+ }
543
+
483
544
  write_manifest() {
484
545
  local root="$1"
485
546
  python3 - "$REPO_ROOT" "$root" "$VERSION" "$REVISION" "$BRANCH" "$SOURCE_DIRTY" "$APP_SUPPORT" "$LOGS_ROOT" "$DEVICES_DB" "$PAIRLING_RUNTIME_PORT" <<'PY'
@@ -535,6 +596,7 @@ for rel in [
535
596
  "companiond/providers/external.py",
536
597
  "companiond/providers/registry.py",
537
598
  "connectd/pairling-connectd",
599
+ "connectd/pairling-tailnet-mintd",
538
600
  "mcp/phone_tools.py",
539
601
  "guardian/companion-power-guardian.py",
540
602
  "guardian/guardian_contract.py",
@@ -567,6 +629,7 @@ manifest = {
567
629
  "daemon_label": "dev.pairling.companiond",
568
630
  "ptybroker_label": "dev.pairling.ptybroker",
569
631
  "connectd_label": "dev.pairling.connectd",
632
+ "mintd_label": "dev.pairling.mintd",
570
633
  "guardian_label": "dev.pairling.power-guardian",
571
634
  },
572
635
  "paths": {
@@ -577,7 +640,6 @@ manifest = {
577
640
  },
578
641
  "migration": {
579
642
  "legacy_port": 7723,
580
- "legacy_daemon_unloaded_by_setup": True,
581
643
  "public_v1_dual_bind": False,
582
644
  },
583
645
  "packaging": {
@@ -691,6 +753,19 @@ SH
691
753
  mv "$tmp" "$target"
692
754
  }
693
755
 
756
+ mintd_provisioned() {
757
+ # Architecture B (minting) is enabled in the companiond env once the
758
+ # separate-uid mint broker has been installed by the explicit, consent-gated
759
+ # `pairling enable-silent-join` flow. The broker's LaunchDaemon plist lives in
760
+ # /Library/LaunchDaemons (root:wheel 0644, world-readable), so this steady-
761
+ # state check needs no elevated privileges, and a plain setup never prompts
762
+ # for a password. Architecture A (browser-login) stays the fallback whenever the
763
+ # broker is absent; the credential gate is enforced at install time by
764
+ # enable_silent_join + install_mintd_if_possible.
765
+ if is_dry_run; then return 1; fi
766
+ [[ -f "$MINTD_SYSTEM_PLIST" ]]
767
+ }
768
+
694
769
  render_plists() {
695
770
  # Prefer the staged vendored interpreter whenever it exists, so start/
696
771
  # rollback (which don't re-stage) also run the daemon under dev.pairling.python.
@@ -698,20 +773,17 @@ render_plists() {
698
773
  if [[ -x "$CURRENT_LINK/python/bin/python3" ]]; then
699
774
  daemon_python="$CURRENT_LINK/python/bin/python3"
700
775
  fi
701
- python3 "$REPO_ROOT/mac/install/render-launchd.py" \
702
- --current-root "$CURRENT_LINK" \
703
- --logs-root "$LOGS_ROOT" \
704
- --output-dir "$PLIST_BUILD_DIR" \
705
- --daemon-python "$daemon_python" \
776
+ local -a render_args=(
777
+ --current-root "$CURRENT_LINK"
778
+ --logs-root "$LOGS_ROOT"
779
+ --output-dir "$PLIST_BUILD_DIR"
780
+ --daemon-python "$daemon_python"
706
781
  --guardian-python "$GUARDIAN_PYTHON_BIN"
707
- }
708
-
709
- unload_legacy_daemon() {
710
- if is_dry_run; then
711
- log "dry-run: would check legacy predecessor cleanup"
712
- return
782
+ )
783
+ if mintd_provisioned; then
784
+ render_args+=(--mint-enabled)
713
785
  fi
714
- return 0
786
+ python3 "$REPO_ROOT/mac/install/render-launchd.py" "${render_args[@]}"
715
787
  }
716
788
 
717
789
  start_user_agent() {
@@ -747,7 +819,20 @@ ptybroker_live_session_count() {
747
819
  import json
748
820
  import sys
749
821
 
750
- payload = json.loads(sys.argv[1])
822
+ def load_json_arg(raw):
823
+ text = str(raw or "").strip()
824
+ decoder = json.JSONDecoder()
825
+ for index, char in enumerate(text):
826
+ if char not in "{[":
827
+ continue
828
+ try:
829
+ value, _ = decoder.raw_decode(text[index:])
830
+ return value
831
+ except json.JSONDecodeError:
832
+ continue
833
+ return {}
834
+
835
+ payload = load_json_arg(sys.argv[1])
751
836
  status = payload.get("status") if isinstance(payload.get("status"), dict) else {}
752
837
  print(status.get("live_session_count", "unknown"))
753
838
  PY
@@ -799,7 +884,20 @@ ptybroker_live_revision() {
799
884
  import json
800
885
  import sys
801
886
 
802
- payload = json.loads(sys.argv[1])
887
+ def load_json_arg(raw):
888
+ text = str(raw or "").strip()
889
+ decoder = json.JSONDecoder()
890
+ for index, char in enumerate(text):
891
+ if char not in "{[":
892
+ continue
893
+ try:
894
+ value, _ = decoder.raw_decode(text[index:])
895
+ return value
896
+ except json.JSONDecodeError:
897
+ continue
898
+ return {}
899
+
900
+ payload = load_json_arg(sys.argv[1])
803
901
  status = payload.get("status") if isinstance(payload.get("status"), dict) else payload
804
902
  print(status.get("source_revision") or "")
805
903
  PY
@@ -813,7 +911,20 @@ import sys
813
911
  from pathlib import Path
814
912
 
815
913
  current = Path(sys.argv[1])
816
- payload = json.loads(sys.argv[2])
914
+ def load_json_arg(raw):
915
+ text = str(raw or "").strip()
916
+ decoder = json.JSONDecoder()
917
+ for index, char in enumerate(text):
918
+ if char not in "{[":
919
+ continue
920
+ try:
921
+ value, _ = decoder.raw_decode(text[index:])
922
+ return value
923
+ except json.JSONDecodeError:
924
+ continue
925
+ return {}
926
+
927
+ payload = load_json_arg(sys.argv[2])
817
928
  live = payload.get("status") if isinstance(payload.get("status"), dict) else payload
818
929
 
819
930
  def read_revision(root: Path):
@@ -881,7 +992,20 @@ ptybroker_report_deferred_restart() {
881
992
  import json
882
993
  import sys
883
994
 
884
- state = json.loads(sys.argv[1])
995
+ def load_json_arg(raw):
996
+ text = str(raw or "").strip()
997
+ decoder = json.JSONDecoder()
998
+ for index, char in enumerate(text):
999
+ if char not in "{[":
1000
+ continue
1001
+ try:
1002
+ value, _ = decoder.raw_decode(text[index:])
1003
+ return value
1004
+ except json.JSONDecodeError:
1005
+ continue
1006
+ return {}
1007
+
1008
+ state = load_json_arg(sys.argv[1])
885
1009
  if state.get("state") != "stale_deferred":
886
1010
  raise SystemExit(0)
887
1011
  live = state.get("live") if isinstance(state.get("live"), dict) else {}
@@ -902,7 +1026,20 @@ ptybroker_state_field() {
902
1026
  import json
903
1027
  import sys
904
1028
 
905
- payload = json.loads(sys.argv[1])
1029
+ def load_json_arg(raw):
1030
+ text = str(raw or "").strip()
1031
+ decoder = json.JSONDecoder()
1032
+ for index, char in enumerate(text):
1033
+ if char not in "{[":
1034
+ continue
1035
+ try:
1036
+ value, _ = decoder.raw_decode(text[index:])
1037
+ return value
1038
+ except json.JSONDecodeError:
1039
+ continue
1040
+ return {}
1041
+
1042
+ payload = load_json_arg(sys.argv[1])
906
1043
  value = payload
907
1044
  for part in sys.argv[2].split("."):
908
1045
  if isinstance(value, dict):
@@ -1011,6 +1148,16 @@ stop_connectd_agent() {
1011
1148
  launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
1012
1149
  }
1013
1150
 
1151
+ stop_mintd_daemon() {
1152
+ if is_dry_run; then
1153
+ log "dry-run: would stop $PAIRLING_MINTD_LABEL"
1154
+ return
1155
+ fi
1156
+ if sudo -n true >/dev/null 2>&1; then
1157
+ sudo launchctl bootout system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
1158
+ fi
1159
+ }
1160
+
1014
1161
  install_guardian_if_possible() {
1015
1162
  local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
1016
1163
  if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
@@ -1033,6 +1180,176 @@ install_guardian_if_possible() {
1033
1180
  fi
1034
1181
  }
1035
1182
 
1183
+ mintd_uid_in_range() {
1184
+ local uid="$1"
1185
+ [[ "$uid" =~ ^[0-9]+$ && "$uid" -ge 450 && "$uid" -le 499 ]]
1186
+ }
1187
+
1188
+ ensure_mintd_service_account() {
1189
+ if dscl . -read /Users/_pairling_mint >/dev/null 2>&1; then
1190
+ local uid real
1191
+ uid="$(dscl . -read /Users/_pairling_mint UniqueID | awk '{print $2}')"
1192
+ real="$(dscl . -read /Users/_pairling_mint RealName 2>/dev/null | sed '1d;s/^ //')"
1193
+ if ! mintd_uid_in_range "$uid"; then
1194
+ log "Skipping mintd install: _pairling_mint UID $uid is outside 450-499." >&2
1195
+ return 1
1196
+ fi
1197
+ if [[ "$real" != "Pairling Mint Broker" ]]; then
1198
+ log "Skipping mintd install: _pairling_mint RealName is not Pairling Mint Broker." >&2
1199
+ return 1
1200
+ fi
1201
+ return 0
1202
+ fi
1203
+ local uid=""
1204
+ for candidate in $(seq 450 499); do
1205
+ if ! dscl . -list /Users UniqueID | awk -v uid="$candidate" '$2 == uid {found=1} END {exit found ? 0 : 1}'; then
1206
+ uid="$candidate"
1207
+ break
1208
+ fi
1209
+ done
1210
+ if [[ -z "$uid" ]]; then
1211
+ log "Skipping mintd install: no free macOS role-account UID in 450-499." >&2
1212
+ return 1
1213
+ fi
1214
+ local pw
1215
+ pw="$(uuidgen)-$(uuidgen)"
1216
+ sudo sysadminctl -addUser _pairling_mint -fullName "Pairling Mint Broker" -UID "$uid" -GID 20 -shell /usr/bin/false -home /var/empty -password "$pw" -roleAccount >/dev/null
1217
+ unset pw
1218
+ }
1219
+
1220
+ install_mintd_if_possible() {
1221
+ local rendered="$PLIST_BUILD_DIR/$PAIRLING_MINTD_LABEL.plist"
1222
+ local mintd_secret="$MINTD_SECRET_DIR/client_secret.json"
1223
+ if is_dry_run; then
1224
+ log "dry-run: would install $PAIRLING_MINTD_LABEL when privileged setup is available"
1225
+ return
1226
+ fi
1227
+ if ! sudo -n true >/dev/null 2>&1; then
1228
+ log "Skipping mintd install: passwordless sudo is unavailable. Architecture A fallback remains available."
1229
+ return
1230
+ fi
1231
+ if ! sudo test -f "$mintd_secret"; then
1232
+ log "Skipping mintd install: OAuth client secret is not provisioned at $mintd_secret."
1233
+ return
1234
+ fi
1235
+ ensure_mintd_service_account || return
1236
+ sudo chmod 0600 "$mintd_secret"
1237
+ sudo chown _pairling_mint:staff "$mintd_secret"
1238
+ sudo install -d -m 0700 -o _pairling_mint -g staff "$MINTD_SECRET_DIR"
1239
+ sudo install -d -m 0750 -o _pairling_mint -g staff "$MINTD_RUN_DIR"
1240
+ sudo install -d -m 0750 -o _pairling_mint -g staff "$MINTD_LOGS_DIR"
1241
+ sudo install -m 0755 -o root -g wheel "$CURRENT_LINK/connectd/pairling-tailnet-mintd" "$MINTD_SYSTEM_BINARY"
1242
+ sudo cp "$rendered" "$MINTD_SYSTEM_PLIST"
1243
+ sudo chown root:wheel "$MINTD_SYSTEM_PLIST"
1244
+ sudo chmod 644 "$MINTD_SYSTEM_PLIST"
1245
+ sudo launchctl bootout system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
1246
+ sudo launchctl bootstrap system "$MINTD_SYSTEM_PLIST" >/dev/null 2>&1 || true
1247
+ sudo launchctl kickstart -k "system/$PAIRLING_MINTD_LABEL"
1248
+ }
1249
+
1250
+ enable_silent_join() {
1251
+ local client_secret_path=""
1252
+ local assume_yes="0"
1253
+ while [[ $# -gt 0 ]]; do
1254
+ case "$1" in
1255
+ --client-secret) shift; client_secret_path="${1:-}" ;;
1256
+ --yes) assume_yes="1" ;;
1257
+ --help|-h) log "usage: pairling enable-silent-join [--client-secret PATH] [--yes]"; return 0 ;;
1258
+ *) log "usage: pairling enable-silent-join [--client-secret PATH] [--yes]" >&2; exit 2 ;;
1259
+ esac
1260
+ shift
1261
+ done
1262
+
1263
+ if is_dry_run; then
1264
+ log "dry-run: would explain the mint broker, request one-time consent, and install $PAIRLING_MINTD_LABEL under interactive sudo"
1265
+ return 0
1266
+ fi
1267
+
1268
+ if [[ ! -x "$CURRENT_LINK/connectd/pairling-tailnet-mintd" ]]; then
1269
+ log "ERROR: the mint broker binary is not staged. Run 'pairling setup' first." >&2
1270
+ exit 1
1271
+ fi
1272
+
1273
+ cat <<EOF
1274
+
1275
+ Enable silent tailnet join (Architecture B)
1276
+ -------------------------------------------
1277
+ This installs a small background service, the mint broker ($PAIRLING_MINTD_LABEL).
1278
+ It runs under its own macOS account (_pairling_mint), not as the Pairling daemon
1279
+ and not as you. It holds your Tailscale OAuth client secret so the Pairling
1280
+ daemon never can: the daemon may ask it for one short-lived, single-use phone
1281
+ key per pairing, and nothing more.
1282
+
1283
+ Installing a system service and a dedicated account needs administrator rights,
1284
+ so you approve this one time now. After that, every future pairing joins your
1285
+ tailnet silently, with no browser step.
1286
+
1287
+ You provide a Tailscale OAuth client (scope auth_keys, tag tag:pairling-phone)
1288
+ that you create in your own Tailscale admin console. The secret is stored
1289
+ readable only by _pairling_mint, is never committed, and is never read by the
1290
+ Pairling daemon. If you skip this, pairing still works over the browser path
1291
+ (Architecture A); silent join stays off.
1292
+
1293
+ EOF
1294
+
1295
+ local secret_json=""
1296
+ if [[ -n "$client_secret_path" ]]; then
1297
+ [[ -f "$client_secret_path" ]] || { log "ERROR: --client-secret file not found: $client_secret_path" >&2; exit 1; }
1298
+ secret_json="$(cat "$client_secret_path")"
1299
+ elif [[ -t 0 ]]; then
1300
+ log "Paste the Tailscale OAuth client JSON (with client_id and client_secret), then press Ctrl-D:"
1301
+ secret_json="$(cat)"
1302
+ else
1303
+ log "ERROR: no Tailscale OAuth client provided. Re-run with --client-secret PATH (a JSON file with client_id and client_secret)." >&2
1304
+ exit 1
1305
+ fi
1306
+
1307
+ if ! printf '%s' "$secret_json" | python3 -c 'import json,sys
1308
+ d = json.load(sys.stdin)
1309
+ sys.exit(0 if isinstance(d, dict) and d.get("client_id") and d.get("client_secret") else 1)' >/dev/null 2>&1; then
1310
+ log "ERROR: the provided credential is not valid JSON with non-empty client_id and client_secret." >&2
1311
+ exit 1
1312
+ fi
1313
+
1314
+ if [[ "$assume_yes" != "1" ]]; then
1315
+ if [[ ! -t 0 ]]; then
1316
+ log "ERROR: enabling silent join needs explicit consent. Re-run with --yes to confirm." >&2
1317
+ exit 1
1318
+ fi
1319
+ printf 'Install the mint broker and enable silent join now? [y/N] '
1320
+ local reply=""
1321
+ read -r reply
1322
+ case "$reply" in
1323
+ y|Y|yes|YES) : ;;
1324
+ *) log "Silent join not enabled. Pairing continues over the browser path."; return 0 ;;
1325
+ esac
1326
+ fi
1327
+
1328
+ if ! sudo -v; then
1329
+ log "Administrator approval was not granted. Silent join stays off; pairing still works over the browser path." >&2
1330
+ exit 1
1331
+ fi
1332
+
1333
+ local mintd_secret="$MINTD_SECRET_DIR/client_secret.json"
1334
+ local tmp_secret
1335
+ tmp_secret="$(mktemp)"
1336
+ chmod 600 "$tmp_secret"
1337
+ printf '%s' "$secret_json" > "$tmp_secret"
1338
+ sudo install -d -m 0700 "$MINTD_SECRET_DIR"
1339
+ sudo install -m 0600 "$tmp_secret" "$mintd_secret"
1340
+ rm -f "$tmp_secret"
1341
+
1342
+ install_mintd_if_possible
1343
+ if [[ ! -f "$MINTD_SYSTEM_PLIST" ]]; then
1344
+ log "ERROR: the mint broker did not install. Silent join is not enabled; pairing still works over the browser path." >&2
1345
+ exit 1
1346
+ fi
1347
+
1348
+ render_plists
1349
+ start_user_agent
1350
+ log "Silent tailnet join is enabled. Run 'pairling pair --qr' to pair; future pairings join your tailnet with no browser step."
1351
+ }
1352
+
1036
1353
  run_doctor() {
1037
1354
  "$REPO_ROOT/mac/install/doctor.sh"
1038
1355
  }
@@ -1066,6 +1383,7 @@ install_runtime() {
1066
1383
  log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
1067
1384
  log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
1068
1385
  log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
1386
+ log " Mint LaunchDaemon: $PAIRLING_MINTD_LABEL"
1069
1387
  log " runtime port: $PAIRLING_RUNTIME_PORT"
1070
1388
  run_compile_checks
1071
1389
  run_psk_dependency_checks
@@ -1075,7 +1393,6 @@ install_runtime() {
1075
1393
  install_mcp_adapter_shim
1076
1394
  install_shell_wrapper
1077
1395
  render_plists
1078
- unload_legacy_daemon
1079
1396
  ensure_ptybroker_agent
1080
1397
  start_user_agent
1081
1398
  start_connectd_agent
@@ -1087,9 +1404,14 @@ install_runtime() {
1087
1404
  run_doctor || true
1088
1405
  fi
1089
1406
  log "Installed Pairling runtime $RELEASE_NAME"
1407
+ if ! mintd_provisioned; then
1408
+ log ""
1409
+ log "Silent tailnet join (no browser step) is available but not yet enabled."
1410
+ log "Turn it on once, using your own Tailscale account: pairling enable-silent-join"
1411
+ fi
1090
1412
  if ! is_dry_run; then
1091
1413
  log ""
1092
- if ! pair_runtime --qr; then
1414
+ if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-35}" pair_runtime --qr; then
1093
1415
  log "Pairling installed, but setup could not generate a pairing invitation. Run: pairling doctor --json; pairling pair --qr" >&2
1094
1416
  exit 1
1095
1417
  fi
@@ -1103,7 +1425,6 @@ status_runtime() {
1103
1425
  start_runtime() {
1104
1426
  ensure_state
1105
1427
  render_plists
1106
- unload_legacy_daemon
1107
1428
  ensure_ptybroker_agent
1108
1429
  start_user_agent
1109
1430
  start_connectd_agent
@@ -1111,6 +1432,7 @@ start_runtime() {
1111
1432
  }
1112
1433
 
1113
1434
  stop_runtime() {
1435
+ stop_mintd_daemon
1114
1436
  stop_connectd_agent
1115
1437
  stop_user_agent
1116
1438
  log "Stopped $PAIRLING_DAEMON_LABEL"
@@ -1156,6 +1478,7 @@ import os
1156
1478
  import socket
1157
1479
  import subprocess
1158
1480
  import sys
1481
+ import time
1159
1482
  import urllib.parse
1160
1483
  import urllib.error
1161
1484
  import urllib.request
@@ -1219,6 +1542,9 @@ mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_n
1219
1542
  # payload — the secret never goes on the wire. Without it the phone falls back to the legacy
1220
1543
  # plaintext claim, so this field is the bridge that actually makes WS3 engage.
1221
1544
  mac_ake_pub = str(payload.get("mac_ake_pub") or (payload.get("claim") or {}).get("mac_ake_pub") or "")
1545
+ # The daemon computes pv authoritatively (3 when minting is enabled server-side,
1546
+ # 2 for PSK-only). Read it from the same top-level-or-claim shape as mac_ake_pub.
1547
+ claim_pv = str(payload.get("pv") or (payload.get("claim") or {}).get("pv") or "")
1222
1548
 
1223
1549
  def is_ats_local_ipv4(value: str) -> bool:
1224
1550
  try:
@@ -1268,34 +1594,64 @@ def detected_tailnet_ip() -> str:
1268
1594
  return ip
1269
1595
  return ""
1270
1596
 
1597
+ def connectd_route_wait_seconds() -> float:
1598
+ try:
1599
+ return min(max(float(os.environ.get("PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS") or "0"), 0.0), 60.0)
1600
+ except ValueError:
1601
+ return 0.0
1602
+
1603
+ def connectd_route_poll_seconds() -> float:
1604
+ try:
1605
+ return min(max(float(os.environ.get("PAIRLING_CONNECTD_ROUTE_POLL_SECONDS") or "0.5"), 0.1), 2.0)
1606
+ except ValueError:
1607
+ return 0.5
1608
+
1609
+ def status_could_be_ready_soon(status: dict) -> bool:
1610
+ if not status:
1611
+ return True
1612
+ if status.get("auth_url_present"):
1613
+ return False
1614
+ return True
1615
+
1616
+ def ready_connectd_route():
1617
+ wait_seconds = connectd_route_wait_seconds()
1618
+ poll_seconds = connectd_route_poll_seconds()
1619
+ deadline = time.monotonic() + wait_seconds
1620
+ while True:
1621
+ status = fetch_connectd_status(timeout_seconds=0.7)
1622
+ connect_routes = advertised_pairling_connect_routes(status)
1623
+ if connect_routes:
1624
+ return connect_routes[0]
1625
+ if wait_seconds <= 0 or time.monotonic() >= deadline or not status_could_be_ready_soon(status):
1626
+ return None
1627
+ time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic())))
1628
+
1271
1629
  def default_pair_route(port_number: int) -> dict:
1272
1630
  for key in ("PAIRLING_PAIR_BASE_URL", "PAIRLING_PUBLIC_BASE_URL"):
1273
1631
  value = os.environ.get(key)
1274
1632
  if value:
1275
1633
  return {"base_url": value, "source": "explicit_override", "status": "override"}
1276
- # First-pair QR claims go through iOS URLSession before any persisted
1277
- # Pairling Connect route exists. TestFlight builds allow local HTTP via
1278
- # NSAllowsLocalNetworking, but ATS blocks plain HTTP to 100.64/10 tailnet
1279
- # addresses. Prefer ATS-local LAN/Bonjour bases for bootstrap; remote
1280
- # tailnet routes are validated and promoted after the claim.
1281
- lan_ip = detected_lan_ip()
1282
- if lan_ip:
1283
- return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
1284
- if os.environ.get("PAIRLING_DISABLE_BONJOUR") != "1" and os.environ.get("PAIRLING_TEST_DISABLE_BONJOUR") != "1":
1285
- return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback", "kind": "bonjour"}
1286
- connect_routes = advertised_pairling_connect_routes(fetch_connectd_status(timeout_seconds=0.7))
1287
- if connect_routes:
1288
- route = connect_routes[0]
1634
+ # Remote-first pairing: if connectd reports a ready Pairling Connect route,
1635
+ # the QR advertises that route and the iOS app claims it through the
1636
+ # embedded pre-pair transport. LAN/Bonjour are explicit degraded fallbacks
1637
+ # when Pairling Connect is not ready.
1638
+ route = ready_connectd_route()
1639
+ if route:
1289
1640
  return {
1290
1641
  "base_url": route["base_url"],
1291
1642
  "source": route["source"],
1292
1643
  "status": route["status"],
1293
1644
  "kind": route["kind"],
1294
1645
  }
1646
+ lan_ip = detected_lan_ip()
1647
+ if lan_ip:
1648
+ return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
1649
+ if os.environ.get("PAIRLING_DISABLE_BONJOUR") != "1" and os.environ.get("PAIRLING_TEST_DISABLE_BONJOUR") != "1":
1650
+ return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback", "kind": "bonjour"}
1295
1651
  tailnet_ip = detected_tailnet_ip()
1296
1652
  if tailnet_ip:
1297
- return {"base_url": f"http://{tailnet_ip}:{port_number}", "source": "standalone_tailnet", "status": "fallback"}
1298
- return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback"}
1653
+ return {"base_url": f"http://{tailnet_ip}:{port_number}", "source": "standalone_tailnet", "status": "fallback", "kind": "standalone_tailnet"}
1654
+ return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback", "kind": "bonjour"}
1299
1655
 
1300
1656
  pair_route = default_pair_route(int(port))
1301
1657
  base_url = str(pair_route.get("base_url") or "")
@@ -1308,14 +1664,34 @@ if pair_id and secret:
1308
1664
  if mac_ake_pub:
1309
1665
  # WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
1310
1666
  # to PSK-authenticated ECDH (secret never transmitted) when both are present; their
1311
- # absence is the legacy plaintext claim. pv=2 == PSK protocol version.
1667
+ # absence is the legacy plaintext claim. pv=3 requests the B sealed-authkey
1668
+ # extension when minting is available; pv=2 remains the PSK-only marker.
1312
1669
  pair_params["mac_ake_pub"] = mac_ake_pub
1313
- pair_params["pv"] = "2"
1670
+ # Prefer the daemon's authoritative claim.pv so the QR can't downgrade to
1671
+ # pv=2 when the CLI shell lacks PAIRLING_MINT_ENABLED (the daemon has the
1672
+ # mint state, the CLI env may not). Fall back to the env marker only for a
1673
+ # legacy daemon that does not advertise pv.
1674
+ mint_enabled = os.environ.get("PAIRLING_MINT_ENABLED", "").strip().lower() in {"1", "true", "yes"}
1675
+ pair_params["pv"] = claim_pv if claim_pv in {"2", "3"} else ("3" if mint_enabled else "2")
1314
1676
  if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
1315
1677
  pair_params["route_source"] = "pairling_connectd"
1316
1678
  pair_params["route_status"] = "ready"
1317
1679
  pair_params["route_kind"] = str(pair_route.get("kind") or "tailnet")
1318
1680
  pair_params["route_contract"] = "pairling-runtime-v1"
1681
+ elif pair_route.get("status") == "fallback":
1682
+ pair_params["route_source"] = "local_fallback"
1683
+ pair_params["route_status"] = "degraded"
1684
+ pair_params["route_kind"] = str(pair_route.get("kind") or pair_route.get("source") or "local")
1685
+ pair_params["route_contract"] = "pairling-runtime-v1"
1686
+ # D1: carry the silent-join capability so the phone can warn before the QR is
1687
+ # consumed. Only emitted when unavailable (e.g. under tailnet lock); the iOS
1688
+ # parser defaults to available when the param is absent.
1689
+ claim_block = payload.get("claim") or {}
1690
+ if claim_block.get("silent_join_available") is False:
1691
+ pair_params["silent_join_available"] = "false"
1692
+ reason = str(claim_block.get("silent_join_unavailable_reason") or "")
1693
+ if reason:
1694
+ pair_params["silent_join_unavailable_reason"] = reason
1319
1695
  manual = {
1320
1696
  "base_url": base_url,
1321
1697
  "pair_id": pair_id,
@@ -1614,6 +1990,7 @@ commands:
1614
1990
  rotate-token <device_id>
1615
1991
  logs
1616
1992
  diagnose --redact
1993
+ enable-silent-join [--client-secret PATH] [--yes]
1617
1994
  uninstall
1618
1995
  rollback
1619
1996
  EOF
@@ -1655,6 +2032,9 @@ case "$cmd" in
1655
2032
  pair)
1656
2033
  pair_runtime "$@"
1657
2034
  ;;
2035
+ enable-silent-join)
2036
+ enable_silent_join "$@"
2037
+ ;;
1658
2038
  devices)
1659
2039
  devices_runtime
1660
2040
  ;;