pairling 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +5 -5
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/app_attest_lan.py +87 -0
- package/payload/mac/companiond/codex_approval.py +69 -0
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +374 -70
- package/payload/mac/companiond/pairling_psk.py +100 -0
- package/payload/mac/companiond/pairling_tools.py +2 -2
- package/payload/mac/companiond/pairlingd.py +977 -104
- package/payload/mac/companiond/pty_broker.py +441 -3
- package/payload/mac/companiond/pty_broker_client.py +167 -0
- package/payload/mac/companiond/pty_broker_service.py +84 -0
- package/payload/mac/companiond/runtime_contract.py +0 -2
- package/payload/mac/companiond/standard_push_publisher.py +7 -0
- package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
- package/payload/mac/connectd/internal/status/status.go +9 -0
- package/payload/mac/install/doctor.sh +227 -51
- package/payload/mac/install/install-runtime.sh +398 -15
- package/payload/mac/install/psk_dependency_check.py +40 -0
- package/payload/mac/install/render-launchd.py +23 -0
- package/payload/mac/install/uninstall-runtime.sh +4 -12
- package/payload-manifest.json +51 -23
|
@@ -34,7 +34,6 @@ PACKAGED_SOURCE_PATHS=(
|
|
|
34
34
|
"mac/connectd/go.mod"
|
|
35
35
|
"mac/connectd/go.sum"
|
|
36
36
|
"mac/guardian"
|
|
37
|
-
"mac/helper-assistant/PairlingHelperAssistant.swift"
|
|
38
37
|
"mac/install"
|
|
39
38
|
"mac/mcp"
|
|
40
39
|
)
|
|
@@ -51,7 +50,7 @@ PAIRLING_RUNTIME_PORT="${PAIRLING_RUNTIME_PORT:-7773}"
|
|
|
51
50
|
PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
|
|
52
51
|
PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
|
|
53
52
|
PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
|
|
54
|
-
|
|
53
|
+
PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
|
|
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"
|
|
@@ -69,19 +68,34 @@ MCP_CREDENTIAL="$APP_SUPPORT/mcp-bridge.json"
|
|
|
69
68
|
INSTALL_HISTORY="$STATE_ROOT/install-history.jsonl"
|
|
70
69
|
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
|
-
LEGACY_SYSTEM_PLIST="/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist"
|
|
75
73
|
MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
|
|
76
74
|
MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
|
|
77
75
|
PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
|
|
78
76
|
GUARDIAN_PYTHON_BIN="${PAIRLING_GUARDIAN_PYTHON:-${COMPANION_GUARDIAN_PYTHON:-/usr/bin/python3}}"
|
|
77
|
+
# P3 Python custody: the npm shim points PAIRLING_DAEMON_PYTHON at the vendored
|
|
78
|
+
# CPython inside the platform runtime package (…/python/bin/python3). When that
|
|
79
|
+
# is in play we stage the whole interpreter into the release tree and run the
|
|
80
|
+
# daemon under it, so a Pairling-signed python (identity dev.pairling.python),
|
|
81
|
+
# not a generic system python3, owns the daemon's TCC grants — and npm churn
|
|
82
|
+
# can't remove the running interpreter.
|
|
83
|
+
PYTHON_CODESIGN_IDENTIFIER="dev.pairling.python"
|
|
79
84
|
DRY_RUN="${PAIRLING_DRY_RUN:-0}"
|
|
80
85
|
|
|
81
86
|
log() {
|
|
82
87
|
printf '%s\n' "$*"
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
display_path() {
|
|
91
|
+
local path="$1"
|
|
92
|
+
case "$path" in
|
|
93
|
+
"$HOME"/*) printf '~/%s\n' "${path#"$HOME"/}" ;;
|
|
94
|
+
"$HOME") printf '~\n' ;;
|
|
95
|
+
*) printf '%s\n' "$path" ;;
|
|
96
|
+
esac
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
is_dry_run() {
|
|
86
100
|
[[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
|
|
87
101
|
}
|
|
@@ -122,9 +136,13 @@ run_compile_checks() {
|
|
|
122
136
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/llm_route.py"
|
|
123
137
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_tools.py"
|
|
124
138
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_pairing.py"
|
|
139
|
+
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_psk.py"
|
|
125
140
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pairling_relay_claims.py"
|
|
126
141
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/request_proof.py"
|
|
142
|
+
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/codex_approval.py"
|
|
127
143
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker.py"
|
|
144
|
+
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker_client.py"
|
|
145
|
+
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/pty_broker_service.py"
|
|
128
146
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_screen_backend.py"
|
|
129
147
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py"
|
|
130
148
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/push_dispatcher.py"
|
|
@@ -150,9 +168,30 @@ run_compile_checks() {
|
|
|
150
168
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/companion-power-guardian.py"
|
|
151
169
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/guardian_contract.py"
|
|
152
170
|
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/render-launchd.py"
|
|
171
|
+
PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/psk_dependency_check.py"
|
|
153
172
|
rm -rf "$pycache_root"
|
|
154
173
|
}
|
|
155
174
|
|
|
175
|
+
run_psk_dependency_import_check() {
|
|
176
|
+
local python_bin="$1"
|
|
177
|
+
local companiond_path="$2"
|
|
178
|
+
local label="$3"
|
|
179
|
+
"$python_bin" "$REPO_ROOT/mac/install/psk_dependency_check.py" "$companiond_path" --label "$label"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
run_psk_dependency_checks() {
|
|
183
|
+
run_psk_dependency_import_check "$PYTHON3_BIN" "$REPO_ROOT/mac/companiond" "source-tree preflight"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
run_staged_psk_dependency_checks() {
|
|
187
|
+
local tmp="$1"
|
|
188
|
+
local staged_python="$PYTHON3_BIN"
|
|
189
|
+
if [[ -x "$tmp/python/bin/python3" ]]; then
|
|
190
|
+
staged_python="$tmp/python/bin/python3"
|
|
191
|
+
fi
|
|
192
|
+
run_psk_dependency_import_check "$staged_python" "$tmp/companiond" "staged runtime copy"
|
|
193
|
+
}
|
|
194
|
+
|
|
156
195
|
ensure_state() {
|
|
157
196
|
mkdir -p "$RELEASES_ROOT" "$STATE_ROOT" "$PAIR_ROOT" "$LOGS_ROOT" "$PLIST_BUILD_DIR" "$APP_SUPPORT/modules"
|
|
158
197
|
chmod 700 "$APP_SUPPORT" "$PAIR_ROOT" 2>/dev/null || true
|
|
@@ -242,9 +281,13 @@ copy_release() {
|
|
|
242
281
|
cp "$REPO_ROOT/mac/companiond/llm_route.py" "$tmp/companiond/"
|
|
243
282
|
cp "$REPO_ROOT/mac/companiond/pairling_tools.py" "$tmp/companiond/"
|
|
244
283
|
cp "$REPO_ROOT/mac/companiond/pairling_pairing.py" "$tmp/companiond/"
|
|
284
|
+
cp "$REPO_ROOT/mac/companiond/pairling_psk.py" "$tmp/companiond/"
|
|
245
285
|
cp "$REPO_ROOT/mac/companiond/pairling_relay_claims.py" "$tmp/companiond/"
|
|
246
286
|
cp "$REPO_ROOT/mac/companiond/request_proof.py" "$tmp/companiond/"
|
|
287
|
+
cp "$REPO_ROOT/mac/companiond/codex_approval.py" "$tmp/companiond/"
|
|
247
288
|
cp "$REPO_ROOT/mac/companiond/pty_broker.py" "$tmp/companiond/"
|
|
289
|
+
cp "$REPO_ROOT/mac/companiond/pty_broker_client.py" "$tmp/companiond/"
|
|
290
|
+
cp "$REPO_ROOT/mac/companiond/pty_broker_service.py" "$tmp/companiond/"
|
|
248
291
|
cp "$REPO_ROOT/mac/companiond/terminal_screen_backend.py" "$tmp/companiond/"
|
|
249
292
|
cp "$REPO_ROOT/mac/companiond/terminal_text_sanitizer.py" "$tmp/companiond/"
|
|
250
293
|
cp "$REPO_ROOT/mac/companiond/push_dispatcher.py" "$tmp/companiond/"
|
|
@@ -263,6 +306,8 @@ copy_release() {
|
|
|
263
306
|
cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
|
|
264
307
|
cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
|
|
265
308
|
build_connectd_binary "$tmp/connectd/pairling-connectd"
|
|
309
|
+
stage_vendored_python "$tmp/python"
|
|
310
|
+
run_staged_psk_dependency_checks "$tmp"
|
|
266
311
|
copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
|
|
267
312
|
write_installed_pairling_launcher "$tmp/bin/pairling"
|
|
268
313
|
chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
|
|
@@ -294,6 +339,10 @@ copy_runtime_source_tree() {
|
|
|
294
339
|
printf '%s\n' "$BRANCH" > "$mac_root/SOURCE_BRANCH"
|
|
295
340
|
printf '%s\n' "$SOURCE_DIRTY" > "$mac_root/SOURCE_DIRTY"
|
|
296
341
|
cp "$REPO_ROOT/mac/companiond/"*.py "$mac_root/companiond/"
|
|
342
|
+
# WS2: co-locate the canonical App Attest validator with the daemon so
|
|
343
|
+
# app_attest_lan can import it in the staged runtime (the repo keeps the one
|
|
344
|
+
# source of truth in relay/). Non-fatal if absent — the gate fails closed.
|
|
345
|
+
cp "$REPO_ROOT/relay/app_attest_validator.py" "$mac_root/companiond/" 2>/dev/null || true
|
|
297
346
|
cp "$REPO_ROOT/mac/companiond/providers/"*.py "$mac_root/companiond/providers/"
|
|
298
347
|
cp "$REPO_ROOT/mac/companiond/integrations/__init__.py" "$mac_root/companiond/integrations/"
|
|
299
348
|
cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$mac_root/companiond/integrations/aperture_cli/"
|
|
@@ -322,6 +371,59 @@ exec "$ROOT/mac/packaging/bin/pairling" "$@"
|
|
|
322
371
|
SH
|
|
323
372
|
}
|
|
324
373
|
|
|
374
|
+
# Stage the vendored CPython (P3 custody) into the release tree when the npm
|
|
375
|
+
# shim provided one via PAIRLING_DAEMON_PYTHON pointing at …/python/bin/python3.
|
|
376
|
+
# Fail-closed: the interpreter must carry a valid signature, the pinned Team ID,
|
|
377
|
+
# and the dev.pairling.python identifier. On success, repoint PYTHON3_BIN at the
|
|
378
|
+
# STAGED interpreter so the daemon plist never references the npm package path.
|
|
379
|
+
stage_vendored_python() {
|
|
380
|
+
local dest="$1"
|
|
381
|
+
local provided="${PAIRLING_DAEMON_PYTHON:-}"
|
|
382
|
+
# Only act on a vendored interpreter living under a runtime package's python/
|
|
383
|
+
# tree. A bare system python3 (no sibling python/ tree) is left as-is.
|
|
384
|
+
case "$provided" in
|
|
385
|
+
*/python/bin/python3) : ;;
|
|
386
|
+
*) return 0 ;;
|
|
387
|
+
esac
|
|
388
|
+
local src_tree
|
|
389
|
+
src_tree="$(cd "$(dirname "$provided")/.." && pwd)"
|
|
390
|
+
if [[ ! -x "$src_tree/bin/python3" ]]; then
|
|
391
|
+
return 0
|
|
392
|
+
fi
|
|
393
|
+
local required_team="${PAIRLING_CONNECTD_TEAM_ID:-965AVD34A3}"
|
|
394
|
+
# Always enforce signature integrity and the dev.pairling.python identity
|
|
395
|
+
# (cert-independent defense in depth). Pin the Apple Team ID unless the dev
|
|
396
|
+
# switch (-) disables that one check for local ad-hoc builds.
|
|
397
|
+
if ! /usr/bin/codesign --verify --strict "$src_tree/bin/python3" >/dev/null 2>&1; then
|
|
398
|
+
log "ERROR: vendored python failed codesign verification; refusing to stage: $src_tree/bin/python3" >&2
|
|
399
|
+
exit 1
|
|
400
|
+
fi
|
|
401
|
+
local team identifier
|
|
402
|
+
identifier="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^Identifier=//p')"
|
|
403
|
+
if [[ "$identifier" != "$PYTHON_CODESIGN_IDENTIFIER" ]]; then
|
|
404
|
+
log "ERROR: vendored python identifier '${identifier:-none}' is not '$PYTHON_CODESIGN_IDENTIFIER'; refusing to stage." >&2
|
|
405
|
+
exit 1
|
|
406
|
+
fi
|
|
407
|
+
if [[ "$required_team" == "-" ]]; then
|
|
408
|
+
log "WARNING: vendored python Team ID pin disabled (PAIRLING_CONNECTD_TEAM_ID=-). Dev builds only."
|
|
409
|
+
else
|
|
410
|
+
team="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
|
|
411
|
+
if [[ "$team" != "$required_team" ]]; then
|
|
412
|
+
log "ERROR: vendored python TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage." >&2
|
|
413
|
+
exit 1
|
|
414
|
+
fi
|
|
415
|
+
fi
|
|
416
|
+
rm -rf "$dest"
|
|
417
|
+
mkdir -p "$(dirname "$dest")"
|
|
418
|
+
cp -R "$src_tree" "$dest"
|
|
419
|
+
chmod 755 "$dest/bin/python3" 2>/dev/null || true
|
|
420
|
+
# Point the daemon at the interpreter through the stable `current` symlink
|
|
421
|
+
# (not $dest, which is the pre-move temp path) so the plist resolves after the
|
|
422
|
+
# release is moved into place and after rollback — exactly like connectd.
|
|
423
|
+
PYTHON3_BIN="$CURRENT_LINK/python/bin/python3"
|
|
424
|
+
log "Staged vendored CPython (daemon will run under dev.pairling.python via $PYTHON3_BIN)"
|
|
425
|
+
}
|
|
426
|
+
|
|
325
427
|
build_connectd_binary() {
|
|
326
428
|
local out="$1"
|
|
327
429
|
# npm-delivered binary: the shim points PAIRLING_CONNECTD_PREBUILT at the
|
|
@@ -404,9 +506,13 @@ for rel in [
|
|
|
404
506
|
"companiond/llm_route.py",
|
|
405
507
|
"companiond/pairling_tools.py",
|
|
406
508
|
"companiond/pairling_pairing.py",
|
|
509
|
+
"companiond/pairling_psk.py",
|
|
407
510
|
"companiond/pairling_relay_claims.py",
|
|
408
511
|
"companiond/request_proof.py",
|
|
512
|
+
"companiond/codex_approval.py",
|
|
409
513
|
"companiond/pty_broker.py",
|
|
514
|
+
"companiond/pty_broker_client.py",
|
|
515
|
+
"companiond/pty_broker_service.py",
|
|
410
516
|
"companiond/terminal_screen_backend.py",
|
|
411
517
|
"companiond/terminal_text_sanitizer.py",
|
|
412
518
|
"companiond/push_dispatcher.py",
|
|
@@ -459,9 +565,9 @@ manifest = {
|
|
|
459
565
|
},
|
|
460
566
|
"launchd": {
|
|
461
567
|
"daemon_label": "dev.pairling.companiond",
|
|
568
|
+
"ptybroker_label": "dev.pairling.ptybroker",
|
|
462
569
|
"connectd_label": "dev.pairling.connectd",
|
|
463
570
|
"guardian_label": "dev.pairling.power-guardian",
|
|
464
|
-
"legacy_daemon_label": "com.mghome.notify-webhook",
|
|
465
571
|
},
|
|
466
572
|
"paths": {
|
|
467
573
|
"app_support": app_support,
|
|
@@ -551,7 +657,7 @@ if [[ -x "$RUNTIME_PAIRLING" ]]; then
|
|
|
551
657
|
exec "$RUNTIME_PAIRLING" "$@"
|
|
552
658
|
fi
|
|
553
659
|
|
|
554
|
-
printf 'Pairling runtime command is not installed.
|
|
660
|
+
printf 'Pairling runtime command is not installed. Run: npm install -g pairling && pairling setup (or use a repo-local mac/packaging/bin/pairling).\n' >&2
|
|
555
661
|
exit 127
|
|
556
662
|
SH
|
|
557
663
|
chmod 755 "$tmp"
|
|
@@ -559,21 +665,26 @@ SH
|
|
|
559
665
|
}
|
|
560
666
|
|
|
561
667
|
render_plists() {
|
|
668
|
+
# Prefer the staged vendored interpreter whenever it exists, so start/
|
|
669
|
+
# rollback (which don't re-stage) also run the daemon under dev.pairling.python.
|
|
670
|
+
local daemon_python="$PYTHON3_BIN"
|
|
671
|
+
if [[ -x "$CURRENT_LINK/python/bin/python3" ]]; then
|
|
672
|
+
daemon_python="$CURRENT_LINK/python/bin/python3"
|
|
673
|
+
fi
|
|
562
674
|
python3 "$REPO_ROOT/mac/install/render-launchd.py" \
|
|
563
675
|
--current-root "$CURRENT_LINK" \
|
|
564
676
|
--logs-root "$LOGS_ROOT" \
|
|
565
677
|
--output-dir "$PLIST_BUILD_DIR" \
|
|
566
|
-
--daemon-python "$
|
|
678
|
+
--daemon-python "$daemon_python" \
|
|
567
679
|
--guardian-python "$GUARDIAN_PYTHON_BIN"
|
|
568
680
|
}
|
|
569
681
|
|
|
570
682
|
unload_legacy_daemon() {
|
|
571
683
|
if is_dry_run; then
|
|
572
|
-
log "dry-run: would
|
|
684
|
+
log "dry-run: would check legacy predecessor cleanup"
|
|
573
685
|
return
|
|
574
686
|
fi
|
|
575
|
-
|
|
576
|
-
launchctl bootout "gui/$(id -u)" "$LEGACY_USER_PLIST" >/dev/null 2>&1 || true
|
|
687
|
+
return 0
|
|
577
688
|
}
|
|
578
689
|
|
|
579
690
|
start_user_agent() {
|
|
@@ -602,6 +713,259 @@ start_connectd_agent() {
|
|
|
602
713
|
launchctl kickstart -k "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL"
|
|
603
714
|
}
|
|
604
715
|
|
|
716
|
+
ptybroker_live_session_count() {
|
|
717
|
+
local status_json
|
|
718
|
+
if status_json="$(ptybroker_status_json 2>/dev/null)"; then
|
|
719
|
+
python3 - "$status_json" <<'PY'
|
|
720
|
+
import json
|
|
721
|
+
import sys
|
|
722
|
+
|
|
723
|
+
payload = json.loads(sys.argv[1])
|
|
724
|
+
status = payload.get("status") if isinstance(payload.get("status"), dict) else {}
|
|
725
|
+
print(status.get("live_session_count", "unknown"))
|
|
726
|
+
PY
|
|
727
|
+
else
|
|
728
|
+
printf '%s\n' "unknown"
|
|
729
|
+
fi
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
ptybroker_status_json() {
|
|
733
|
+
"$PYTHON3_BIN" - "$CURRENT_LINK" <<'PY'
|
|
734
|
+
import json
|
|
735
|
+
import sys
|
|
736
|
+
from pathlib import Path
|
|
737
|
+
|
|
738
|
+
current = Path(sys.argv[1])
|
|
739
|
+
sys.path.insert(0, str(current / "companiond"))
|
|
740
|
+
from pty_broker_client import PTYBrokerClient, ensure_pty_broker_token
|
|
741
|
+
|
|
742
|
+
companion = Path.home() / ".claude" / "companion"
|
|
743
|
+
client = PTYBrokerClient(companion / "pty-broker.sock", ensure_pty_broker_token(companion), timeout=1.0)
|
|
744
|
+
print(json.dumps({"ok": True, "status": client.status()}, sort_keys=True))
|
|
745
|
+
PY
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
ptybroker_desired_revision() {
|
|
749
|
+
python3 - "$CURRENT_LINK" <<'PY'
|
|
750
|
+
import json
|
|
751
|
+
import sys
|
|
752
|
+
from pathlib import Path
|
|
753
|
+
|
|
754
|
+
current = Path(sys.argv[1])
|
|
755
|
+
for path in [current / "manifest.json", current / "mac" / "SOURCE_REVISION", current / "SOURCE_REVISION"]:
|
|
756
|
+
try:
|
|
757
|
+
if path.name == "manifest.json":
|
|
758
|
+
print(json.loads(path.read_text()).get("source_revision") or "")
|
|
759
|
+
else:
|
|
760
|
+
print(path.read_text().strip())
|
|
761
|
+
raise SystemExit(0)
|
|
762
|
+
except FileNotFoundError:
|
|
763
|
+
continue
|
|
764
|
+
except Exception:
|
|
765
|
+
continue
|
|
766
|
+
print("")
|
|
767
|
+
PY
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
ptybroker_live_revision() {
|
|
771
|
+
python3 - "${1:-{}}" <<'PY'
|
|
772
|
+
import json
|
|
773
|
+
import sys
|
|
774
|
+
|
|
775
|
+
payload = json.loads(sys.argv[1])
|
|
776
|
+
status = payload.get("status") if isinstance(payload.get("status"), dict) else payload
|
|
777
|
+
print(status.get("source_revision") or "")
|
|
778
|
+
PY
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
ptybroker_deployment_state_json() {
|
|
782
|
+
python3 - "$CURRENT_LINK" "${1:-{}}" <<'PY'
|
|
783
|
+
import json
|
|
784
|
+
import os
|
|
785
|
+
import sys
|
|
786
|
+
from pathlib import Path
|
|
787
|
+
|
|
788
|
+
current = Path(sys.argv[1])
|
|
789
|
+
payload = json.loads(sys.argv[2])
|
|
790
|
+
live = payload.get("status") if isinstance(payload.get("status"), dict) else payload
|
|
791
|
+
|
|
792
|
+
def read_revision(root: Path):
|
|
793
|
+
for path in [root / "manifest.json", root / "mac" / "SOURCE_REVISION", root / "SOURCE_REVISION"]:
|
|
794
|
+
try:
|
|
795
|
+
if path.name == "manifest.json":
|
|
796
|
+
return json.loads(path.read_text()).get("source_revision")
|
|
797
|
+
value = path.read_text().strip()
|
|
798
|
+
return value or None
|
|
799
|
+
except FileNotFoundError:
|
|
800
|
+
continue
|
|
801
|
+
except Exception:
|
|
802
|
+
continue
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
desired_root = current.resolve()
|
|
806
|
+
desired = {
|
|
807
|
+
"runtime_root": str(desired_root),
|
|
808
|
+
"script_path": str(desired_root / "companiond" / "pty_broker_service.py"),
|
|
809
|
+
"source_revision": read_revision(desired_root),
|
|
810
|
+
"protocol_version": 1,
|
|
811
|
+
}
|
|
812
|
+
reasons = []
|
|
813
|
+
live_root = live.get("runtime_root")
|
|
814
|
+
if live_root:
|
|
815
|
+
if os.path.realpath(str(live_root)) != str(desired_root):
|
|
816
|
+
reasons.append("runtime_root_mismatch")
|
|
817
|
+
else:
|
|
818
|
+
reasons.append("runtime_root_missing")
|
|
819
|
+
live_script = live.get("script_path")
|
|
820
|
+
if live_script:
|
|
821
|
+
if os.path.realpath(str(live_script)) != str(desired["script_path"]):
|
|
822
|
+
reasons.append("script_path_mismatch")
|
|
823
|
+
else:
|
|
824
|
+
reasons.append("script_path_missing")
|
|
825
|
+
live_revision = live.get("source_revision")
|
|
826
|
+
if desired["source_revision"] and not live_revision:
|
|
827
|
+
reasons.append("source_revision_missing")
|
|
828
|
+
elif live_revision and desired["source_revision"] and str(live_revision) != str(desired["source_revision"]):
|
|
829
|
+
reasons.append("source_revision_mismatch")
|
|
830
|
+
try:
|
|
831
|
+
live_protocol = int(live.get("protocol_version") or 0)
|
|
832
|
+
except (TypeError, ValueError):
|
|
833
|
+
live_protocol = 0
|
|
834
|
+
if live_protocol != desired["protocol_version"]:
|
|
835
|
+
if not live.get("protocol_version"):
|
|
836
|
+
reasons.append("protocol_version_missing")
|
|
837
|
+
else:
|
|
838
|
+
reasons.append("protocol_version_mismatch")
|
|
839
|
+
state = "current" if not reasons else "stale_deferred"
|
|
840
|
+
print(json.dumps({
|
|
841
|
+
"state": state,
|
|
842
|
+
"restart_deferred": state == "stale_deferred",
|
|
843
|
+
"reasons": reasons,
|
|
844
|
+
"desired": desired,
|
|
845
|
+
"live": live,
|
|
846
|
+
}, sort_keys=True))
|
|
847
|
+
PY
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
ptybroker_report_deferred_restart() {
|
|
851
|
+
local state_json
|
|
852
|
+
state_json="$(ptybroker_deployment_state_json "$1")"
|
|
853
|
+
python3 - "$state_json" <<'PY'
|
|
854
|
+
import json
|
|
855
|
+
import sys
|
|
856
|
+
|
|
857
|
+
state = json.loads(sys.argv[1])
|
|
858
|
+
if state.get("state") != "stale_deferred":
|
|
859
|
+
raise SystemExit(0)
|
|
860
|
+
live = state.get("live") if isinstance(state.get("live"), dict) else {}
|
|
861
|
+
desired = state.get("desired") if isinstance(state.get("desired"), dict) else {}
|
|
862
|
+
print(
|
|
863
|
+
"WARNING: ptybroker running older code; normal install preserved live PTYs; "
|
|
864
|
+
"broker restart is deferred; "
|
|
865
|
+
f"live_source_revision={live.get('source_revision')} "
|
|
866
|
+
f"desired_source_revision={desired.get('source_revision')} "
|
|
867
|
+
f"live_pid={live.get('pid')} "
|
|
868
|
+
f"live_session_count={live.get('live_session_count')}"
|
|
869
|
+
)
|
|
870
|
+
PY
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
ptybroker_state_field() {
|
|
874
|
+
python3 - "$1" "$2" <<'PY'
|
|
875
|
+
import json
|
|
876
|
+
import sys
|
|
877
|
+
|
|
878
|
+
payload = json.loads(sys.argv[1])
|
|
879
|
+
value = payload
|
|
880
|
+
for part in sys.argv[2].split("."):
|
|
881
|
+
if isinstance(value, dict):
|
|
882
|
+
value = value.get(part)
|
|
883
|
+
else:
|
|
884
|
+
value = None
|
|
885
|
+
print("" if value is None else value)
|
|
886
|
+
PY
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
ensure_ptybroker_agent() {
|
|
890
|
+
mkdir -p "$HOME/Library/LaunchAgents"
|
|
891
|
+
local rendered="$PLIST_BUILD_DIR/$PAIRLING_PTYBROKER_LABEL.plist"
|
|
892
|
+
local changed=0
|
|
893
|
+
if [[ ! -f "$PTYBROKER_USER_PLIST" ]] || ! cmp -s "$rendered" "$PTYBROKER_USER_PLIST"; then
|
|
894
|
+
cp "$rendered" "$PTYBROKER_USER_PLIST"
|
|
895
|
+
chmod 644 "$PTYBROKER_USER_PLIST"
|
|
896
|
+
changed=1
|
|
897
|
+
fi
|
|
898
|
+
if is_dry_run; then
|
|
899
|
+
if [[ "$changed" == "1" ]]; then
|
|
900
|
+
log "dry-run: rendered $PTYBROKER_USER_PLIST"
|
|
901
|
+
else
|
|
902
|
+
log "dry-run: $PTYBROKER_USER_PLIST unchanged"
|
|
903
|
+
fi
|
|
904
|
+
return
|
|
905
|
+
fi
|
|
906
|
+
if ! launchctl print "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1; then
|
|
907
|
+
launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
|
|
908
|
+
launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
|
|
909
|
+
return
|
|
910
|
+
fi
|
|
911
|
+
local status_json
|
|
912
|
+
if status_json="$(ptybroker_status_json 2>/dev/null)"; then
|
|
913
|
+
ptybroker_report_deferred_restart "$status_json"
|
|
914
|
+
else
|
|
915
|
+
log "WARNING: ptybroker status unreachable_socket; normal install preserved live PTYs but broker freshness is unknown; broker restart is deferred"
|
|
916
|
+
fi
|
|
917
|
+
if [[ "$changed" == "1" ]]; then
|
|
918
|
+
local live_count
|
|
919
|
+
live_count="$(ptybroker_live_session_count)"
|
|
920
|
+
log "ptybroker plist changed but broker is already loaded; preserving PTYs and deferring broker restart (live_sessions=$live_count)"
|
|
921
|
+
fi
|
|
922
|
+
if [[ ! -S "$HOME/.claude/companion/pty-broker.sock" ]]; then
|
|
923
|
+
launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
|
|
924
|
+
fi
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
reconcile_ptybroker() {
|
|
928
|
+
ensure_state
|
|
929
|
+
render_plists
|
|
930
|
+
mkdir -p "$HOME/Library/LaunchAgents"
|
|
931
|
+
cp "$PLIST_BUILD_DIR/$PAIRLING_PTYBROKER_LABEL.plist" "$PTYBROKER_USER_PLIST"
|
|
932
|
+
chmod 644 "$PTYBROKER_USER_PLIST"
|
|
933
|
+
if is_dry_run; then
|
|
934
|
+
log "dry-run: would reconcile $PAIRLING_PTYBROKER_LABEL"
|
|
935
|
+
return
|
|
936
|
+
fi
|
|
937
|
+
if ! launchctl print "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1; then
|
|
938
|
+
launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
|
|
939
|
+
launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
|
|
940
|
+
log "Started $PAIRLING_PTYBROKER_LABEL"
|
|
941
|
+
return
|
|
942
|
+
fi
|
|
943
|
+
local status_json state_json live_count live_pid
|
|
944
|
+
if ! status_json="$(ptybroker_status_json 2>/dev/null)"; then
|
|
945
|
+
log "ERROR: ptybroker is loaded but status RPC is unreachable; refusing reconcile until socket is reachable or broker is manually stopped." >&2
|
|
946
|
+
exit 1
|
|
947
|
+
fi
|
|
948
|
+
state_json="$(ptybroker_deployment_state_json "$status_json")"
|
|
949
|
+
live_count="$(ptybroker_state_field "$state_json" "live.live_session_count")"
|
|
950
|
+
live_pid="$(ptybroker_state_field "$state_json" "live.pid")"
|
|
951
|
+
if [[ "${live_count:-0}" != "0" ]]; then
|
|
952
|
+
log "ERROR: ptybroker restart deferred: live_session_count=$live_count live_pid=$live_pid; close/drain live PTYs before broker code can be updated." >&2
|
|
953
|
+
exit 1
|
|
954
|
+
fi
|
|
955
|
+
log "Operator requested idle ptybroker reconcile; restarting broker live_pid=$live_pid live_session_count=0"
|
|
956
|
+
launchctl bootout "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
|
|
957
|
+
launchctl bootout "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
|
|
958
|
+
launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
|
|
959
|
+
launchctl kickstart -k "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL"
|
|
960
|
+
status_json="$(ptybroker_status_json)"
|
|
961
|
+
state_json="$(ptybroker_deployment_state_json "$status_json")"
|
|
962
|
+
if [[ "$(ptybroker_state_field "$state_json" "state")" != "current" ]]; then
|
|
963
|
+
log "ERROR: ptybroker restart completed but status is not current: $state_json" >&2
|
|
964
|
+
exit 1
|
|
965
|
+
fi
|
|
966
|
+
log "Reconciled $PAIRLING_PTYBROKER_LABEL with current runtime"
|
|
967
|
+
}
|
|
968
|
+
|
|
605
969
|
stop_user_agent() {
|
|
606
970
|
if is_dry_run; then
|
|
607
971
|
log "dry-run: would stop $PAIRLING_DAEMON_LABEL"
|
|
@@ -623,7 +987,7 @@ stop_connectd_agent() {
|
|
|
623
987
|
install_guardian_if_possible() {
|
|
624
988
|
local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
|
|
625
989
|
if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
|
|
626
|
-
log "
|
|
990
|
+
log "Optional power guardian not installed; pairing can continue without the privileged sleep helper."
|
|
627
991
|
return
|
|
628
992
|
fi
|
|
629
993
|
if is_dry_run; then
|
|
@@ -643,7 +1007,7 @@ install_guardian_if_possible() {
|
|
|
643
1007
|
}
|
|
644
1008
|
|
|
645
1009
|
run_doctor() {
|
|
646
|
-
"$REPO_ROOT/mac/install/doctor.sh"
|
|
1010
|
+
"$REPO_ROOT/mac/install/doctor.sh"
|
|
647
1011
|
}
|
|
648
1012
|
|
|
649
1013
|
rollback() {
|
|
@@ -661,6 +1025,7 @@ rollback() {
|
|
|
661
1025
|
ln -s "$current_target" "$PREVIOUS_LINK"
|
|
662
1026
|
fi
|
|
663
1027
|
render_plists
|
|
1028
|
+
ensure_ptybroker_agent
|
|
664
1029
|
start_user_agent
|
|
665
1030
|
start_connectd_agent
|
|
666
1031
|
append_history "rollback" "rolled back to $previous_target"
|
|
@@ -669,13 +1034,14 @@ rollback() {
|
|
|
669
1034
|
|
|
670
1035
|
install_runtime() {
|
|
671
1036
|
log "Pairling setup preview:"
|
|
672
|
-
log " app support: $APP_SUPPORT"
|
|
673
|
-
log " logs: $LOGS_ROOT"
|
|
1037
|
+
log " app support: $(display_path "$APP_SUPPORT")"
|
|
1038
|
+
log " logs: $(display_path "$LOGS_ROOT")"
|
|
674
1039
|
log " LaunchAgent: $PAIRLING_DAEMON_LABEL"
|
|
1040
|
+
log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
|
|
675
1041
|
log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
|
|
676
1042
|
log " runtime port: $PAIRLING_RUNTIME_PORT"
|
|
677
|
-
log " old Pairling predecessor cleanup label: $LEGACY_DAEMON_LABEL"
|
|
678
1043
|
run_compile_checks
|
|
1044
|
+
run_psk_dependency_checks
|
|
679
1045
|
ensure_state
|
|
680
1046
|
copy_release
|
|
681
1047
|
switch_current
|
|
@@ -683,6 +1049,7 @@ install_runtime() {
|
|
|
683
1049
|
install_shell_wrapper
|
|
684
1050
|
render_plists
|
|
685
1051
|
unload_legacy_daemon
|
|
1052
|
+
ensure_ptybroker_agent
|
|
686
1053
|
start_user_agent
|
|
687
1054
|
start_connectd_agent
|
|
688
1055
|
install_guardian_if_possible
|
|
@@ -703,6 +1070,7 @@ start_runtime() {
|
|
|
703
1070
|
ensure_state
|
|
704
1071
|
render_plists
|
|
705
1072
|
unload_legacy_daemon
|
|
1073
|
+
ensure_ptybroker_agent
|
|
706
1074
|
start_user_agent
|
|
707
1075
|
start_connectd_agent
|
|
708
1076
|
log "Started $PAIRLING_DAEMON_LABEL"
|
|
@@ -811,6 +1179,11 @@ secret = str(
|
|
|
811
1179
|
)
|
|
812
1180
|
install_id = str(payload.get("install_id") or "")
|
|
813
1181
|
mac_name = str(((payload.get("pair_service") or {}).get("txt") or {}).get("mac_name") or socket.gethostname())
|
|
1182
|
+
# WS3: the Mac ephemeral ECDH public key (base64url) from /pair/start. Carrying it in the
|
|
1183
|
+
# pair URL is what lets the phone run PSK-authenticated ECDH from the OUT-OF-BAND (QR/paste)
|
|
1184
|
+
# payload — the secret never goes on the wire. Without it the phone falls back to the legacy
|
|
1185
|
+
# plaintext claim, so this field is the bridge that actually makes WS3 engage.
|
|
1186
|
+
mac_ake_pub = str(payload.get("mac_ake_pub") or (payload.get("claim") or {}).get("mac_ake_pub") or "")
|
|
814
1187
|
|
|
815
1188
|
def detected_tailnet_ip() -> str:
|
|
816
1189
|
override = os.environ.get("PAIRLING_TEST_TAILSCALE_IP")
|
|
@@ -867,6 +1240,12 @@ if pair_id and secret:
|
|
|
867
1240
|
"pair_id": pair_id,
|
|
868
1241
|
"secret": secret,
|
|
869
1242
|
}
|
|
1243
|
+
if mac_ake_pub:
|
|
1244
|
+
# WS3: out-of-band delivery of the Mac ECDH key + protocol marker. The phone routes
|
|
1245
|
+
# to PSK-authenticated ECDH (secret never transmitted) when both are present; their
|
|
1246
|
+
# absence is the legacy plaintext claim. pv=2 == PSK protocol version.
|
|
1247
|
+
pair_params["mac_ake_pub"] = mac_ake_pub
|
|
1248
|
+
pair_params["pv"] = "2"
|
|
870
1249
|
if pair_route.get("source") == "pairling_connectd" and pair_route.get("status") == "ready":
|
|
871
1250
|
pair_params["route_source"] = "pairling_connectd"
|
|
872
1251
|
pair_params["route_status"] = "ready"
|
|
@@ -1162,6 +1541,7 @@ commands:
|
|
|
1162
1541
|
status
|
|
1163
1542
|
doctor --json
|
|
1164
1543
|
doctor --first-run --json
|
|
1544
|
+
reconcile-ptybroker
|
|
1165
1545
|
pair
|
|
1166
1546
|
connect-auth-open
|
|
1167
1547
|
devices
|
|
@@ -1204,6 +1584,9 @@ case "$cmd" in
|
|
|
1204
1584
|
doctor)
|
|
1205
1585
|
"$REPO_ROOT/mac/install/doctor.sh" "$@"
|
|
1206
1586
|
;;
|
|
1587
|
+
reconcile-ptybroker|--reconcile-ptybroker|--restart-ptybroker-if-idle)
|
|
1588
|
+
reconcile_ptybroker
|
|
1589
|
+
;;
|
|
1207
1590
|
pair)
|
|
1208
1591
|
pair_runtime "$@"
|
|
1209
1592
|
;;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(description="Verify Pairling PSK pairing import dependencies.")
|
|
12
|
+
parser.add_argument("companiond_path")
|
|
13
|
+
parser.add_argument("--label", default="dependency check")
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
companiond = Path(args.companiond_path).resolve()
|
|
17
|
+
sys.path.insert(0, str(companiond))
|
|
18
|
+
os.environ.pop("PAIRLING_PSK_REQUIRED", None)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import cryptography # noqa: F401
|
|
22
|
+
import pairling_psk # noqa: F401
|
|
23
|
+
import pairling_pairing # noqa: F401
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
print(
|
|
26
|
+
"PSK pairing dependency check failed during "
|
|
27
|
+
f"{args.label}: companiond_path={companiond}; Python must import "
|
|
28
|
+
"cryptography, pairling_psk, and pairling_pairing while "
|
|
29
|
+
"PAIRLING_PSK_REQUIRED is default-on; otherwise PSK pairing is "
|
|
30
|
+
"unavailable/fail-closed and daemon liveness alone is insufficient "
|
|
31
|
+
"(pairing endpoints may return pairing_unavailable). "
|
|
32
|
+
f"Cause: {type(exc).__name__}: {exc}",
|
|
33
|
+
file=sys.stderr,
|
|
34
|
+
)
|
|
35
|
+
return 1
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
raise SystemExit(main())
|