pairling 0.2.5 → 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 +3 -3
  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 +299 -20
  28. package/payload/mac/install/render-launchd.py +54 -10
  29. package/payload-manifest.json +63 -21
@@ -8,6 +8,61 @@ import (
8
8
  "testing"
9
9
  )
10
10
 
11
+ func hasRouteKind(routes []AdvertisedRoute, kind string) bool {
12
+ for _, r := range routes {
13
+ if r.Kind == kind {
14
+ return true
15
+ }
16
+ }
17
+ return false
18
+ }
19
+
20
+ func TestFunnelRouteIsAdditiveAndGated(t *testing.T) {
21
+ s := NewStore("pairling-test")
22
+ s.SetListenerRunning(true)
23
+ s.SetUpstreamReachable(true)
24
+ s.SetTailnetIP("100.64.0.1")
25
+ s.SetAuthenticated()
26
+
27
+ // Funnel disabled: only the tailnet route, and no funnel_hostname in the JSON.
28
+ snap := s.Snapshot()
29
+ if hasRouteKind(snap.AdvertisedRoutes, RouteKindFunnel) {
30
+ t.Error("funnel route present while funnel disabled")
31
+ }
32
+ js, _ := json.Marshal(snap)
33
+ if strings.Contains(string(js), "funnel_hostname") {
34
+ t.Error("funnel_hostname must be omitted when empty")
35
+ }
36
+
37
+ // Enable funnel: the route appears with an https base URL and a lower priority.
38
+ s.SetFunnelHostname("pairling-abc.tail1234.ts.net")
39
+ snap = s.Snapshot()
40
+ var funnel, tailnet *AdvertisedRoute
41
+ for i := range snap.AdvertisedRoutes {
42
+ switch snap.AdvertisedRoutes[i].Kind {
43
+ case RouteKindFunnel:
44
+ funnel = &snap.AdvertisedRoutes[i]
45
+ case RouteKindTailnet:
46
+ tailnet = &snap.AdvertisedRoutes[i]
47
+ }
48
+ }
49
+ if funnel == nil || tailnet == nil {
50
+ t.Fatalf("expected tailnet and funnel routes, got %+v", snap.AdvertisedRoutes)
51
+ }
52
+ if funnel.BaseURL != "https://pairling-abc.tail1234.ts.net" {
53
+ t.Errorf("funnel base_url = %q", funnel.BaseURL)
54
+ }
55
+ if funnel.Priority >= tailnet.Priority {
56
+ t.Errorf("funnel priority %d must be below tailnet priority %d", funnel.Priority, tailnet.Priority)
57
+ }
58
+
59
+ // Unhealthy node: no routes advertise, even with a funnel hostname set.
60
+ s.SetUpstreamReachable(false)
61
+ if len(s.Snapshot().AdvertisedRoutes) != 0 {
62
+ t.Error("no routes should advertise when the node is unhealthy")
63
+ }
64
+ }
65
+
11
66
  func TestStoreServesHelperReadableSnapshotWithoutSecrets(t *testing.T) {
12
67
  store := NewStore("pairling-inst-abcdef")
13
68
  store.SetAuthPending("https://login.tailscale.com/a/secret-auth-token")
@@ -46,6 +101,89 @@ func TestStoreServesHelperReadableSnapshotWithoutSecrets(t *testing.T) {
46
101
  }
47
102
  }
48
103
 
104
+ func TestSnapshotSerializesTailnetIdentityFields(t *testing.T) {
105
+ store := NewStore("pairling-inst-abcdef")
106
+ store.SetTailnetIdentity("nXb6CNTRL", []string{"tag:pairling-connect"}, []string{"100.79.217.7"})
107
+
108
+ raw, err := json.Marshal(store.Snapshot())
109
+ if err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ body := string(raw)
113
+ for _, want := range []string{
114
+ `"tailnet_node_id":"nXb6CNTRL"`,
115
+ `"tags":["tag:pairling-connect"]`,
116
+ `"tailnet_ips":["100.79.217.7"]`,
117
+ } {
118
+ if !strings.Contains(body, want) {
119
+ t.Fatalf("snapshot missing %s: %s", want, body)
120
+ }
121
+ }
122
+ }
123
+
124
+ func TestSnapshotSerializesKnownTailnetLockStatus(t *testing.T) {
125
+ store := NewStore("pairling-inst-abcdef")
126
+ store.SetTailnetLockEnabled(false)
127
+
128
+ raw, err := json.Marshal(store.Snapshot())
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ body := string(raw)
133
+ if !strings.Contains(body, `"tailnet_lock_enabled":false`) {
134
+ t.Fatalf("snapshot missing known lock status: %s", body)
135
+ }
136
+ }
137
+
138
+ func TestSnapshotOmitsTailnetIdentityWhenUnknown(t *testing.T) {
139
+ raw, err := json.Marshal(NewStore("pairling-inst-abcdef").Snapshot())
140
+ if err != nil {
141
+ t.Fatal(err)
142
+ }
143
+ body := string(raw)
144
+ for _, forbidden := range []string{"tailnet_node_id", "tags", "tailnet_ips"} {
145
+ if strings.Contains(body, forbidden) {
146
+ t.Fatalf("fresh snapshot should omit %q: %s", forbidden, body)
147
+ }
148
+ }
149
+ if !strings.Contains(body, `"auth_state":"starting"`) {
150
+ t.Fatalf("fresh snapshot lost coherent auth state: %s", body)
151
+ }
152
+ }
153
+
154
+ func TestSetTailnetIdentityIsIdempotentAndReplaces(t *testing.T) {
155
+ store := NewStore("pairling-inst-abcdef")
156
+ store.SetTailnetIdentity("old-node", []string{"tag:old"}, []string{"100.64.0.1"})
157
+ store.SetTailnetIdentity("new-node", []string{"tag:pairling-connect"}, []string{"100.79.217.7", "fd7a:115c:a1e0::1"})
158
+
159
+ snapshot := store.Snapshot()
160
+ if snapshot.TailnetNodeID != "new-node" {
161
+ t.Fatalf("node ID = %q, want new-node", snapshot.TailnetNodeID)
162
+ }
163
+ if got, want := strings.Join(snapshot.Tags, ","), "tag:pairling-connect"; got != want {
164
+ t.Fatalf("tags = %q, want %q", got, want)
165
+ }
166
+ if got, want := strings.Join(snapshot.TailnetIPs, ","), "100.79.217.7,fd7a:115c:a1e0::1"; got != want {
167
+ t.Fatalf("tailnet IPs = %q, want %q", got, want)
168
+ }
169
+ }
170
+
171
+ func TestTailnetIdentityCarriesNoSecrets(t *testing.T) {
172
+ store := NewStore("pairling-inst-abcdef")
173
+ store.SetTailnetIdentity("tskey-auth-example", []string{"AuthKey=bad"}, []string{"NLPrivate=bad"})
174
+
175
+ raw, err := json.Marshal(store.Snapshot())
176
+ if err != nil {
177
+ t.Fatal(err)
178
+ }
179
+ body := string(raw)
180
+ for _, forbidden := range []string{"tskey", "authkey", "AuthKey", "NLPrivate"} {
181
+ if strings.Contains(body, forbidden) {
182
+ t.Fatalf("tailnet identity leaked %q: %s", forbidden, body)
183
+ }
184
+ }
185
+ }
186
+
49
187
  func TestStoreAdvertisesRouteOnlyWhenReady(t *testing.T) {
50
188
  store := NewStore("pairling-inst-abcdef")
51
189
  store.SetAuthPending("open https://login.tailscale.com/a/example-token")
@@ -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() {
@@ -1076,6 +1148,16 @@ stop_connectd_agent() {
1076
1148
  launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
1077
1149
  }
1078
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
+
1079
1161
  install_guardian_if_possible() {
1080
1162
  local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
1081
1163
  if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
@@ -1098,6 +1180,176 @@ install_guardian_if_possible() {
1098
1180
  fi
1099
1181
  }
1100
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
+
1101
1353
  run_doctor() {
1102
1354
  "$REPO_ROOT/mac/install/doctor.sh"
1103
1355
  }
@@ -1131,6 +1383,7 @@ install_runtime() {
1131
1383
  log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
1132
1384
  log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
1133
1385
  log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
1386
+ log " Mint LaunchDaemon: $PAIRLING_MINTD_LABEL"
1134
1387
  log " runtime port: $PAIRLING_RUNTIME_PORT"
1135
1388
  run_compile_checks
1136
1389
  run_psk_dependency_checks
@@ -1140,7 +1393,6 @@ install_runtime() {
1140
1393
  install_mcp_adapter_shim
1141
1394
  install_shell_wrapper
1142
1395
  render_plists
1143
- unload_legacy_daemon
1144
1396
  ensure_ptybroker_agent
1145
1397
  start_user_agent
1146
1398
  start_connectd_agent
@@ -1152,6 +1404,11 @@ install_runtime() {
1152
1404
  run_doctor || true
1153
1405
  fi
1154
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
1155
1412
  if ! is_dry_run; then
1156
1413
  log ""
1157
1414
  if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-35}" pair_runtime --qr; then
@@ -1168,7 +1425,6 @@ status_runtime() {
1168
1425
  start_runtime() {
1169
1426
  ensure_state
1170
1427
  render_plists
1171
- unload_legacy_daemon
1172
1428
  ensure_ptybroker_agent
1173
1429
  start_user_agent
1174
1430
  start_connectd_agent
@@ -1176,6 +1432,7 @@ start_runtime() {
1176
1432
  }
1177
1433
 
1178
1434
  stop_runtime() {
1435
+ stop_mintd_daemon
1179
1436
  stop_connectd_agent
1180
1437
  stop_user_agent
1181
1438
  log "Stopped $PAIRLING_DAEMON_LABEL"
@@ -1285,6 +1542,9 @@ mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_n
1285
1542
  # payload — the secret never goes on the wire. Without it the phone falls back to the legacy
1286
1543
  # plaintext claim, so this field is the bridge that actually makes WS3 engage.
1287
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 "")
1288
1548
 
1289
1549
  def is_ats_local_ipv4(value: str) -> bool:
1290
1550
  try:
@@ -1404,9 +1664,15 @@ if pair_id and secret:
1404
1664
  if mac_ake_pub:
1405
1665
  # WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
1406
1666
  # to PSK-authenticated ECDH (secret never transmitted) when both are present; their
1407
- # 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.
1408
1669
  pair_params["mac_ake_pub"] = mac_ake_pub
1409
- 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")
1410
1676
  if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
1411
1677
  pair_params["route_source"] = "pairling_connectd"
1412
1678
  pair_params["route_status"] = "ready"
@@ -1417,6 +1683,15 @@ if pair_id and secret:
1417
1683
  pair_params["route_status"] = "degraded"
1418
1684
  pair_params["route_kind"] = str(pair_route.get("kind") or pair_route.get("source") or "local")
1419
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
1420
1695
  manual = {
1421
1696
  "base_url": base_url,
1422
1697
  "pair_id": pair_id,
@@ -1715,6 +1990,7 @@ commands:
1715
1990
  rotate-token <device_id>
1716
1991
  logs
1717
1992
  diagnose --redact
1993
+ enable-silent-join [--client-secret PATH] [--yes]
1718
1994
  uninstall
1719
1995
  rollback
1720
1996
  EOF
@@ -1756,6 +2032,9 @@ case "$cmd" in
1756
2032
  pair)
1757
2033
  pair_runtime "$@"
1758
2034
  ;;
2035
+ enable-silent-join)
2036
+ enable_silent_join "$@"
2037
+ ;;
1759
2038
  devices)
1760
2039
  devices_runtime
1761
2040
  ;;