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.
- package/README.md +11 -9
- package/bin/pairling.mjs +5 -2
- package/package.json +7 -7
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairling_connectd_status.py +57 -7
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +67 -20
- package/payload/mac/companiond/pairlingd.py +269 -16
- package/payload/mac/companiond/push_dispatcher.py +31 -1
- package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
- package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
- package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
- package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
- package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
- package/payload/mac/connectd/internal/runtime/config.go +19 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
- package/payload/mac/connectd/internal/status/status.go +67 -1
- package/payload/mac/connectd/internal/status/status_test.go +138 -0
- package/payload/mac/install/install-runtime.sh +421 -41
- package/payload/mac/install/render-launchd.py +54 -10
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1277
|
-
#
|
|
1278
|
-
#
|
|
1279
|
-
#
|
|
1280
|
-
|
|
1281
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
;;
|