pairling 0.2.6 → 0.2.8
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 +10 -11
- package/bin/pairling.mjs +1 -4
- package/package.json +3 -3
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +1 -143
- package/payload/mac/install/install-runtime.sh +113 -294
- package/payload/mac/install/render-launchd.py +2 -45
- package/payload/mac/install/uninstall-runtime.sh +32 -0
- package/payload-manifest.json +11 -33
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +0 -121
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +0 -418
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +0 -894
|
@@ -51,7 +51,6 @@ 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"
|
|
55
54
|
APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
|
|
56
55
|
RUNTIME_ROOT="$APP_SUPPORT/runtime"
|
|
57
56
|
RELEASES_ROOT="$RUNTIME_ROOT/releases"
|
|
@@ -71,12 +70,6 @@ USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
|
|
|
71
70
|
CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
|
|
72
71
|
PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
|
|
73
72
|
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"
|
|
80
73
|
MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
|
|
81
74
|
MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
|
|
82
75
|
PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
|
|
@@ -313,13 +306,12 @@ copy_release() {
|
|
|
313
306
|
cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
|
|
314
307
|
cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
|
|
315
308
|
build_connectd_binary "$tmp/connectd/pairling-connectd"
|
|
316
|
-
build_mintd_binary "$tmp/connectd/pairling-tailnet-mintd"
|
|
317
309
|
stage_vendored_python "$tmp/python"
|
|
318
310
|
run_staged_psk_dependency_checks "$tmp"
|
|
319
|
-
copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
|
|
311
|
+
copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
|
|
320
312
|
write_installed_pairling_launcher "$tmp/bin/pairling"
|
|
321
313
|
chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
|
|
322
|
-
chmod 755 "$tmp/connectd/pairling-connectd"
|
|
314
|
+
chmod 755 "$tmp/connectd/pairling-connectd"
|
|
323
315
|
chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py "$tmp/guardian/"*.py
|
|
324
316
|
chmod 644 "$tmp/companiond/providers/"*.py
|
|
325
317
|
chmod 644 "$tmp/companiond/integrations/"*.py "$tmp/companiond/integrations/aperture_cli/"*.py
|
|
@@ -333,7 +325,6 @@ copy_release() {
|
|
|
333
325
|
copy_runtime_source_tree() {
|
|
334
326
|
local mac_root="$1"
|
|
335
327
|
local connectd_binary="$2"
|
|
336
|
-
local mintd_binary="$3"
|
|
337
328
|
mkdir -p \
|
|
338
329
|
"$mac_root/companiond" \
|
|
339
330
|
"$mac_root/companiond/providers" \
|
|
@@ -360,13 +351,12 @@ copy_runtime_source_tree() {
|
|
|
360
351
|
cp -R "$REPO_ROOT/mac/connectd/cmd" "$mac_root/connectd/"
|
|
361
352
|
cp -R "$REPO_ROOT/mac/connectd/internal" "$mac_root/connectd/"
|
|
362
353
|
cp "$connectd_binary" "$mac_root/connectd/bin/pairling-connectd"
|
|
363
|
-
cp "$mintd_binary" "$mac_root/connectd/bin/pairling-tailnet-mintd"
|
|
364
354
|
cp "$REPO_ROOT/mac/guardian/"*.py "$mac_root/guardian/"
|
|
365
355
|
cp "$REPO_ROOT/mac/install/"*.sh "$mac_root/install/"
|
|
366
356
|
cp "$REPO_ROOT/mac/install/"*.py "$mac_root/install/"
|
|
367
357
|
cp "$REPO_ROOT/mac/mcp/"*.py "$mac_root/mcp/"
|
|
368
358
|
cp "$REPO_ROOT/mac/packaging/bin/pairling" "$mac_root/packaging/bin/"
|
|
369
|
-
chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/
|
|
359
|
+
chmod 755 "$mac_root/connectd/bin/pairling-connectd" "$mac_root/install/"*.sh "$mac_root/mcp/phone_tools.py" "$mac_root/packaging/bin/pairling"
|
|
370
360
|
chmod 644 "$mac_root/VERSION" "$mac_root/SOURCE_REVISION" "$mac_root/SOURCE_BRANCH" "$mac_root/SOURCE_DIRTY"
|
|
371
361
|
}
|
|
372
362
|
|
|
@@ -490,57 +480,6 @@ build_connectd_binary() {
|
|
|
490
480
|
)
|
|
491
481
|
}
|
|
492
482
|
|
|
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
|
-
|
|
544
483
|
write_manifest() {
|
|
545
484
|
local root="$1"
|
|
546
485
|
python3 - "$REPO_ROOT" "$root" "$VERSION" "$REVISION" "$BRANCH" "$SOURCE_DIRTY" "$APP_SUPPORT" "$LOGS_ROOT" "$DEVICES_DB" "$PAIRLING_RUNTIME_PORT" <<'PY'
|
|
@@ -596,7 +535,6 @@ for rel in [
|
|
|
596
535
|
"companiond/providers/external.py",
|
|
597
536
|
"companiond/providers/registry.py",
|
|
598
537
|
"connectd/pairling-connectd",
|
|
599
|
-
"connectd/pairling-tailnet-mintd",
|
|
600
538
|
"mcp/phone_tools.py",
|
|
601
539
|
"guardian/companion-power-guardian.py",
|
|
602
540
|
"guardian/guardian_contract.py",
|
|
@@ -629,7 +567,6 @@ manifest = {
|
|
|
629
567
|
"daemon_label": "dev.pairling.companiond",
|
|
630
568
|
"ptybroker_label": "dev.pairling.ptybroker",
|
|
631
569
|
"connectd_label": "dev.pairling.connectd",
|
|
632
|
-
"mintd_label": "dev.pairling.mintd",
|
|
633
570
|
"guardian_label": "dev.pairling.power-guardian",
|
|
634
571
|
},
|
|
635
572
|
"paths": {
|
|
@@ -753,19 +690,6 @@ SH
|
|
|
753
690
|
mv "$tmp" "$target"
|
|
754
691
|
}
|
|
755
692
|
|
|
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
|
-
|
|
769
693
|
render_plists() {
|
|
770
694
|
# Prefer the staged vendored interpreter whenever it exists, so start/
|
|
771
695
|
# rollback (which don't re-stage) also run the daemon under dev.pairling.python.
|
|
@@ -780,9 +704,6 @@ render_plists() {
|
|
|
780
704
|
--daemon-python "$daemon_python"
|
|
781
705
|
--guardian-python "$GUARDIAN_PYTHON_BIN"
|
|
782
706
|
)
|
|
783
|
-
if mintd_provisioned; then
|
|
784
|
-
render_args+=(--mint-enabled)
|
|
785
|
-
fi
|
|
786
707
|
python3 "$REPO_ROOT/mac/install/render-launchd.py" "${render_args[@]}"
|
|
787
708
|
}
|
|
788
709
|
|
|
@@ -1148,16 +1069,6 @@ stop_connectd_agent() {
|
|
|
1148
1069
|
launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
|
|
1149
1070
|
}
|
|
1150
1071
|
|
|
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
|
-
|
|
1161
1072
|
install_guardian_if_possible() {
|
|
1162
1073
|
local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
|
|
1163
1074
|
if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
|
|
@@ -1180,176 +1091,6 @@ install_guardian_if_possible() {
|
|
|
1180
1091
|
fi
|
|
1181
1092
|
}
|
|
1182
1093
|
|
|
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
|
-
|
|
1353
1094
|
run_doctor() {
|
|
1354
1095
|
"$REPO_ROOT/mac/install/doctor.sh"
|
|
1355
1096
|
}
|
|
@@ -1383,7 +1124,6 @@ install_runtime() {
|
|
|
1383
1124
|
log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
|
|
1384
1125
|
log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
|
|
1385
1126
|
log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
|
|
1386
|
-
log " Mint LaunchDaemon: $PAIRLING_MINTD_LABEL"
|
|
1387
1127
|
log " runtime port: $PAIRLING_RUNTIME_PORT"
|
|
1388
1128
|
run_compile_checks
|
|
1389
1129
|
run_psk_dependency_checks
|
|
@@ -1404,12 +1144,11 @@ install_runtime() {
|
|
|
1404
1144
|
run_doctor || true
|
|
1405
1145
|
fi
|
|
1406
1146
|
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
|
|
1412
1147
|
if ! is_dry_run; then
|
|
1148
|
+
# Kick off the Tailscale sign-in before the QR so the pairing code can
|
|
1149
|
+
# advertise the now-ready Pairling Connect tailnet route instead of
|
|
1150
|
+
# downgrading to LAN/Bonjour. Never blocks or fails setup.
|
|
1151
|
+
auto_open_connect_auth
|
|
1413
1152
|
log ""
|
|
1414
1153
|
if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-35}" pair_runtime --qr; then
|
|
1415
1154
|
log "Pairling installed, but setup could not generate a pairing invitation. Run: pairling doctor --json; pairling pair --qr" >&2
|
|
@@ -1432,7 +1171,6 @@ start_runtime() {
|
|
|
1432
1171
|
}
|
|
1433
1172
|
|
|
1434
1173
|
stop_runtime() {
|
|
1435
|
-
stop_mintd_daemon
|
|
1436
1174
|
stop_connectd_agent
|
|
1437
1175
|
stop_user_agent
|
|
1438
1176
|
log "Stopped $PAIRLING_DAEMON_LABEL"
|
|
@@ -1542,9 +1280,6 @@ mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_n
|
|
|
1542
1280
|
# payload — the secret never goes on the wire. Without it the phone falls back to the legacy
|
|
1543
1281
|
# plaintext claim, so this field is the bridge that actually makes WS3 engage.
|
|
1544
1282
|
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 "")
|
|
1548
1283
|
|
|
1549
1284
|
def is_ats_local_ipv4(value: str) -> bool:
|
|
1550
1285
|
try:
|
|
@@ -1664,15 +1399,10 @@ if pair_id and secret:
|
|
|
1664
1399
|
if mac_ake_pub:
|
|
1665
1400
|
# WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
|
|
1666
1401
|
# to PSK-authenticated ECDH (secret never transmitted) when both are present; their
|
|
1667
|
-
# absence is the legacy plaintext claim. pv=
|
|
1668
|
-
# extension when minting is available; pv=2 remains the PSK-only marker.
|
|
1402
|
+
# absence is the legacy plaintext claim. pv=2 is the PSK-only marker.
|
|
1669
1403
|
pair_params["mac_ake_pub"] = mac_ake_pub
|
|
1670
|
-
#
|
|
1671
|
-
|
|
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")
|
|
1404
|
+
# pv is always 2 when the Mac ECDH key is present (PSK-authenticated ECDH).
|
|
1405
|
+
pair_params["pv"] = "2"
|
|
1676
1406
|
if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
|
|
1677
1407
|
pair_params["route_source"] = "pairling_connectd"
|
|
1678
1408
|
pair_params["route_status"] = "ready"
|
|
@@ -1683,15 +1413,6 @@ if pair_id and secret:
|
|
|
1683
1413
|
pair_params["route_status"] = "degraded"
|
|
1684
1414
|
pair_params["route_kind"] = str(pair_route.get("kind") or pair_route.get("source") or "local")
|
|
1685
1415
|
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
|
|
1695
1416
|
manual = {
|
|
1696
1417
|
"base_url": base_url,
|
|
1697
1418
|
"pair_id": pair_id,
|
|
@@ -1891,6 +1612,109 @@ connect_auth_open() {
|
|
|
1891
1612
|
exit 1
|
|
1892
1613
|
}
|
|
1893
1614
|
|
|
1615
|
+
# auto_open_connect_auth — kicked off by install_runtime BEFORE the pairing QR.
|
|
1616
|
+
# Polls connectd /status for readiness (reusing fetch_connectd_status, the same
|
|
1617
|
+
# helper pair_runtime imports). When connectd is in interactive mode and not yet
|
|
1618
|
+
# authenticated (auth_url_present == true AND auth_state != "authenticated"), it
|
|
1619
|
+
# POSTs http://127.0.0.1:7774/auth/open directly so connectd opens the browser
|
|
1620
|
+
# sign-in server-side. We POST directly (not via connect_auth_open, which ends in
|
|
1621
|
+
# `exit` and would terminate setup before the QR). Already-authenticated or
|
|
1622
|
+
# already-tagged connectd is skipped silently. This never fails setup and never
|
|
1623
|
+
# blocks indefinitely: the bounded poll falls through to pair_runtime regardless.
|
|
1624
|
+
auto_open_connect_auth() {
|
|
1625
|
+
if is_dry_run; then
|
|
1626
|
+
return 0
|
|
1627
|
+
fi
|
|
1628
|
+
python3 - "$REPO_ROOT" <<'PY' || true
|
|
1629
|
+
import json
|
|
1630
|
+
import os
|
|
1631
|
+
import sys
|
|
1632
|
+
import time
|
|
1633
|
+
import urllib.error
|
|
1634
|
+
import urllib.request
|
|
1635
|
+
|
|
1636
|
+
repo_root = sys.argv[1]
|
|
1637
|
+
sys.path.insert(0, os.path.join(repo_root, "mac", "companiond"))
|
|
1638
|
+
from pairling_connectd_status import fetch_connectd_status
|
|
1639
|
+
|
|
1640
|
+
AUTH_OPEN_URL = "http://127.0.0.1:7774/auth/open"
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def readiness_wait_seconds() -> float:
|
|
1644
|
+
try:
|
|
1645
|
+
return min(max(float(os.environ.get("PAIRLING_CONNECTD_AUTH_WAIT_SECONDS") or "20"), 0.0), 60.0)
|
|
1646
|
+
except ValueError:
|
|
1647
|
+
return 20.0
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def readiness_poll_seconds() -> float:
|
|
1651
|
+
try:
|
|
1652
|
+
return min(max(float(os.environ.get("PAIRLING_CONNECTD_AUTH_POLL_SECONDS") or "0.5"), 0.1), 2.0)
|
|
1653
|
+
except ValueError:
|
|
1654
|
+
return 0.5
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
def decision(status: dict):
|
|
1658
|
+
"""Return one of: ("open",), ("skip", reason), ("wait",)."""
|
|
1659
|
+
if not status:
|
|
1660
|
+
# connectd not reachable yet — keep waiting until the deadline.
|
|
1661
|
+
return ("wait",)
|
|
1662
|
+
auth_state = str(status.get("auth_state") or "")
|
|
1663
|
+
if auth_state == "authenticated":
|
|
1664
|
+
return ("skip", "already authenticated")
|
|
1665
|
+
tags = status.get("tags")
|
|
1666
|
+
if isinstance(tags, list) and tags:
|
|
1667
|
+
# Already-tagged connectd (machine identity established) — no browser step.
|
|
1668
|
+
return ("skip", "already tagged")
|
|
1669
|
+
if status.get("auth_url_present"):
|
|
1670
|
+
return ("open",)
|
|
1671
|
+
# Reachable but no interactive auth URL yet — give connectd a moment.
|
|
1672
|
+
return ("wait",)
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def post_auth_open() -> bool:
|
|
1676
|
+
request = urllib.request.Request(AUTH_OPEN_URL, data=b"", method="POST")
|
|
1677
|
+
try:
|
|
1678
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
1679
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1680
|
+
except urllib.error.HTTPError as exc:
|
|
1681
|
+
# connectd answered but the auth URL is not ready (409) or similar.
|
|
1682
|
+
try:
|
|
1683
|
+
payload = json.loads(exc.read().decode("utf-8"))
|
|
1684
|
+
except Exception:
|
|
1685
|
+
payload = {}
|
|
1686
|
+
return bool(payload.get("opened"))
|
|
1687
|
+
except Exception:
|
|
1688
|
+
return False
|
|
1689
|
+
return bool(payload.get("opened"))
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def main() -> None:
|
|
1693
|
+
wait_seconds = readiness_wait_seconds()
|
|
1694
|
+
poll_seconds = readiness_poll_seconds()
|
|
1695
|
+
deadline = time.monotonic() + wait_seconds
|
|
1696
|
+
while True:
|
|
1697
|
+
status = fetch_connectd_status(timeout_seconds=0.7)
|
|
1698
|
+
action = decision(status)
|
|
1699
|
+
if action[0] == "open":
|
|
1700
|
+
if post_auth_open():
|
|
1701
|
+
print("Opened the Tailscale sign-in in your browser. Finish sign-in to bring Pairling Connect online; the pairing code follows.")
|
|
1702
|
+
else:
|
|
1703
|
+
print("Pairling Connect sign-in is not ready yet; continuing with the pairing code. You can re-run sign-in later with: pairling connect-auth-open")
|
|
1704
|
+
return
|
|
1705
|
+
if action[0] == "skip":
|
|
1706
|
+
# Already authenticated or tagged — no browser step needed.
|
|
1707
|
+
return
|
|
1708
|
+
if wait_seconds <= 0 or time.monotonic() >= deadline:
|
|
1709
|
+
print("Pairling Connect was not ready in time; continuing with the pairing code. You can sign in later with: pairling connect-auth-open")
|
|
1710
|
+
return
|
|
1711
|
+
time.sleep(min(poll_seconds, max(0.0, deadline - time.monotonic())))
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
main()
|
|
1715
|
+
PY
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1894
1718
|
render_pair_qr() {
|
|
1895
1719
|
local pair_url="$1"
|
|
1896
1720
|
if ! command -v swift >/dev/null 2>&1; then
|
|
@@ -1984,13 +1808,11 @@ commands:
|
|
|
1984
1808
|
doctor --first-run --json
|
|
1985
1809
|
reconcile-ptybroker
|
|
1986
1810
|
pair
|
|
1987
|
-
connect-auth-open
|
|
1988
1811
|
devices
|
|
1989
1812
|
unpair <device_id>
|
|
1990
1813
|
rotate-token <device_id>
|
|
1991
1814
|
logs
|
|
1992
1815
|
diagnose --redact
|
|
1993
|
-
enable-silent-join [--client-secret PATH] [--yes]
|
|
1994
1816
|
uninstall
|
|
1995
1817
|
rollback
|
|
1996
1818
|
EOF
|
|
@@ -2032,9 +1854,6 @@ case "$cmd" in
|
|
|
2032
1854
|
pair)
|
|
2033
1855
|
pair_runtime "$@"
|
|
2034
1856
|
;;
|
|
2035
|
-
enable-silent-join)
|
|
2036
|
-
enable_silent_join "$@"
|
|
2037
|
-
;;
|
|
2038
1857
|
devices)
|
|
2039
1858
|
devices_runtime
|
|
2040
1859
|
;;
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
-
import os
|
|
8
7
|
import plistlib
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
@@ -12,7 +11,6 @@ PAIRLING_DAEMON_LABEL = "dev.pairling.companiond"
|
|
|
12
11
|
PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
|
|
13
12
|
PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
|
|
14
13
|
PAIRLING_PTYBROKER_LABEL = "dev.pairling.ptybroker"
|
|
15
|
-
PAIRLING_MINTD_LABEL = "dev.pairling.mintd"
|
|
16
14
|
PAIRLING_RUNTIME_PORT = "7773"
|
|
17
15
|
|
|
18
16
|
|
|
@@ -22,7 +20,7 @@ def write_plist(path: Path, payload: dict) -> None:
|
|
|
22
20
|
plistlib.dump(payload, fh, sort_keys=False)
|
|
23
21
|
|
|
24
22
|
|
|
25
|
-
def daemon_plist(current: Path, logs: Path, python_bin: str
|
|
23
|
+
def daemon_plist(current: Path, logs: Path, python_bin: str) -> dict:
|
|
26
24
|
env = {
|
|
27
25
|
"PAIRLING_RUNTIME_PORT": PAIRLING_RUNTIME_PORT,
|
|
28
26
|
"COMPANION_DAEMON_PORT": PAIRLING_RUNTIME_PORT,
|
|
@@ -31,11 +29,6 @@ def daemon_plist(current: Path, logs: Path, python_bin: str, mint_enabled: bool
|
|
|
31
29
|
"PAIRLING_LOGS_ROOT": str(logs),
|
|
32
30
|
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
33
31
|
}
|
|
34
|
-
if mint_enabled:
|
|
35
|
-
# Architecture B is on only when the separate-uid mint broker is
|
|
36
|
-
# installed. Persisting the flag here keeps it across daemon restarts
|
|
37
|
-
# and reboots, instead of relying on an ephemeral launchctl setenv.
|
|
38
|
-
env["PAIRLING_MINT_ENABLED"] = "1"
|
|
39
32
|
return {
|
|
40
33
|
"Label": PAIRLING_DAEMON_LABEL,
|
|
41
34
|
"ProgramArguments": [
|
|
@@ -104,39 +97,6 @@ def connectd_plist(current: Path, logs: Path) -> dict:
|
|
|
104
97
|
}
|
|
105
98
|
|
|
106
99
|
|
|
107
|
-
def mintd_plist(current: Path, logs: Path) -> dict:
|
|
108
|
-
system_root = Path("/Library/Application Support/Pairling")
|
|
109
|
-
system_logs = Path("/Library/Logs/Pairling")
|
|
110
|
-
return {
|
|
111
|
-
"Label": PAIRLING_MINTD_LABEL,
|
|
112
|
-
"UserName": "_pairling_mint",
|
|
113
|
-
"GroupName": "staff",
|
|
114
|
-
"ProgramArguments": [
|
|
115
|
-
str(system_root / "mint" / "pairling-tailnet-mintd"),
|
|
116
|
-
"--secret-path",
|
|
117
|
-
str(system_root / "mint" / "client_secret.json"),
|
|
118
|
-
"--socket-path",
|
|
119
|
-
str(system_root / "run" / "mintd" / "mintd.sock"),
|
|
120
|
-
"--state-path",
|
|
121
|
-
str(system_root / "mint" / "state.json"),
|
|
122
|
-
"--audit-path",
|
|
123
|
-
str(system_root / "mint" / "audit.jsonl"),
|
|
124
|
-
"--alert-path",
|
|
125
|
-
str(system_root / "run" / "mintd" / "alerts.jsonl"),
|
|
126
|
-
"--authorized-uid",
|
|
127
|
-
str(os.getuid()),
|
|
128
|
-
],
|
|
129
|
-
"EnvironmentVariables": {
|
|
130
|
-
"PATH": "/Applications/Tailscale.app/Contents/MacOS:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
131
|
-
},
|
|
132
|
-
"RunAtLoad": True,
|
|
133
|
-
"KeepAlive": True,
|
|
134
|
-
"ThrottleInterval": 10,
|
|
135
|
-
"StandardOutPath": str(system_logs / "mintd.log"),
|
|
136
|
-
"StandardErrorPath": str(system_logs / "mintd.err"),
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
100
|
def ptybroker_plist(current: Path, logs: Path, python_bin: str) -> dict:
|
|
141
101
|
app_support = current.parent.parent
|
|
142
102
|
return {
|
|
@@ -166,19 +126,16 @@ def main() -> int:
|
|
|
166
126
|
parser.add_argument("--daemon-python", default="/usr/local/bin/python3")
|
|
167
127
|
parser.add_argument("--guardian-python", default="/usr/bin/python3")
|
|
168
128
|
parser.add_argument("--mirror-python", default="/usr/local/bin/python3", help=argparse.SUPPRESS)
|
|
169
|
-
parser.add_argument("--mint-enabled", action="store_true",
|
|
170
|
-
help="set PAIRLING_MINT_ENABLED=1 in the companiond env (Architecture B)")
|
|
171
129
|
args = parser.parse_args()
|
|
172
130
|
|
|
173
131
|
current = Path(args.current_root)
|
|
174
132
|
logs = Path(args.logs_root)
|
|
175
133
|
out = Path(args.output_dir)
|
|
176
134
|
|
|
177
|
-
write_plist(out / f"{PAIRLING_DAEMON_LABEL}.plist", daemon_plist(current, logs, args.daemon_python
|
|
135
|
+
write_plist(out / f"{PAIRLING_DAEMON_LABEL}.plist", daemon_plist(current, logs, args.daemon_python))
|
|
178
136
|
write_plist(out / f"{PAIRLING_PTYBROKER_LABEL}.plist", ptybroker_plist(current, logs, args.daemon_python))
|
|
179
137
|
write_plist(out / f"{PAIRLING_GUARDIAN_LABEL}.plist", guardian_plist(current, logs, args.guardian_python))
|
|
180
138
|
write_plist(out / f"{PAIRLING_CONNECTD_LABEL}.plist", connectd_plist(current, logs))
|
|
181
|
-
write_plist(out / f"{PAIRLING_MINTD_LABEL}.plist", mintd_plist(current, logs))
|
|
182
139
|
return 0
|
|
183
140
|
|
|
184
141
|
|