pairling 0.2.0 → 0.2.2
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 +160 -18
- package/payload/mac/install/install-runtime.sh +329 -12
- 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
package/README.md
CHANGED
|
@@ -69,4 +69,4 @@ notarized, and hash-pinned by this package's integrity manifest.
|
|
|
69
69
|
|
|
70
70
|
- Product: https://pairling.dev
|
|
71
71
|
- Get started: https://pairling.dev/start
|
|
72
|
-
- Source mirror & publish pipeline: https://
|
|
72
|
+
- Source mirror & publish pipeline: https://pairling.dev
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pairling",
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
],
|
|
14
14
|
"homepage": "https://pairling.dev",
|
|
15
15
|
"bugs": {
|
|
16
|
-
"url": "https://
|
|
16
|
+
"url": "https://pairling.dev/support"
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "
|
|
20
|
+
"url": "https://pairling.dev"
|
|
21
21
|
},
|
|
22
22
|
"license": "UNLICENSED",
|
|
23
23
|
"author": "Pairling (https://pairling.dev)",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"access": "public"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"@pairling/runtime-darwin-arm64": "0.2.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.2",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.2"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
feat/onestream-pairling-integration
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
a1eaa1ac
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
0.2.2
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WS2: direct-LAN Apple App Attest verification for /pair/claim.
|
|
3
|
+
|
|
4
|
+
Reuses the canonical AppleAppAttestValidator (relay/app_attest_validator.py) so
|
|
5
|
+
the intricate attestation crypto (CBOR parse, X.509 chain to Apple's App Attest
|
|
6
|
+
root, nonce binding, counter) lives in exactly one place.
|
|
7
|
+
|
|
8
|
+
Purpose: turning this gate on stops the trivial "published 30-line PoC" LAN
|
|
9
|
+
race — a non-genuine client cannot produce a valid Apple attestation. The
|
|
10
|
+
clientData canonical binds the attestation to (pair_id, attest_challenge), so a
|
|
11
|
+
MITM cannot swap the challenge for one it pre-attested against (Blocker #6).
|
|
12
|
+
|
|
13
|
+
Fail-closed: when required but the validator/root cert is unavailable, callers
|
|
14
|
+
treat a verification error as a rejected claim.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
LAN_CANONICAL_PREFIX = "pair.lan.claim.v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def direct_attest_required() -> bool:
|
|
27
|
+
return os.environ.get("PAIRLING_DIRECT_ATTEST_REQUIRED", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def canonical(pair_id: str, attest_challenge: str) -> str:
|
|
31
|
+
"""The frozen clientData string the iOS app hashes and the Mac re-derives.
|
|
32
|
+
Binds the attestation to this specific pairing invitation."""
|
|
33
|
+
return f"{LAN_CANONICAL_PREFIX}\n{pair_id}\n{attest_challenge}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _team_id() -> str:
|
|
37
|
+
return os.environ.get("PAIRLING_APP_ATTEST_TEAM_ID", "965AVD34A3").strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bundle_id() -> str:
|
|
41
|
+
return os.environ.get("PAIRLING_APP_ATTEST_BUNDLE_ID", "dev.pairling.ios").strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_validator = None
|
|
45
|
+
_validator_error: Exception | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_validator():
|
|
49
|
+
global _validator, _validator_error
|
|
50
|
+
if _validator is not None:
|
|
51
|
+
return _validator
|
|
52
|
+
if _validator_error is not None:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
try:
|
|
56
|
+
from app_attest_validator import AppleAppAttestValidator # staged copy
|
|
57
|
+
except Exception:
|
|
58
|
+
relay_dir = Path(__file__).resolve().parents[2] / "relay"
|
|
59
|
+
if str(relay_dir) not in sys.path:
|
|
60
|
+
sys.path.insert(0, str(relay_dir))
|
|
61
|
+
from app_attest_validator import AppleAppAttestValidator
|
|
62
|
+
root_path = os.environ.get("PAIRLING_APP_ATTEST_ROOT_CERT")
|
|
63
|
+
if not root_path:
|
|
64
|
+
local = Path(__file__).resolve().parent / "apple-app-attest-root-ca.pem"
|
|
65
|
+
root_path = str(local) if local.exists() else None
|
|
66
|
+
_validator = AppleAppAttestValidator(root_cert_path=root_path)
|
|
67
|
+
return _validator
|
|
68
|
+
except Exception as exc: # missing cryptography, missing root cert, etc.
|
|
69
|
+
_validator_error = exc
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def verify_attestation(*, attestation: dict, pair_id: str, attest_challenge: str, key_id: str, environment: str) -> bool:
|
|
74
|
+
"""Raise on any failure; return True only on a fully-valid Apple attestation
|
|
75
|
+
bound to this invitation. Fail-closed when the validator is unavailable."""
|
|
76
|
+
validator = _load_validator()
|
|
77
|
+
if validator is None:
|
|
78
|
+
raise RuntimeError(f"app attest validator unavailable: {_validator_error}")
|
|
79
|
+
validator.validate_attestation(
|
|
80
|
+
attestation=attestation,
|
|
81
|
+
challenge=canonical(pair_id, attest_challenge),
|
|
82
|
+
key_id=key_id,
|
|
83
|
+
bundle_id=_bundle_id(),
|
|
84
|
+
team_id=_team_id(),
|
|
85
|
+
environment=(environment or "production"),
|
|
86
|
+
)
|
|
87
|
+
return True
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_APPROVAL_HEADER = re.compile(r"would you like to run|allow .* to run|run the following command", re.I)
|
|
8
|
+
_CMD_LINE = re.compile(r"^\s*\$\s+(?P<cmd>.+?)\s*$")
|
|
9
|
+
_RAW_YES = re.compile(r"(?:^|\s|[>›])1[.)]\s*Yes(?:,\s*)?\s*proceed\b|Yes,\s*proceed", re.I)
|
|
10
|
+
_PATCH_HINT = re.compile(r"\b(apply_patch|patch|modify|edit|write)\b", re.I)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def classify_codex_approval(pending: dict[str, Any] | None, rows: list[str], *, screen_key: str = "") -> dict[str, Any] | None:
|
|
14
|
+
if pending and pending.get("state") not in {None, "awaiting_selection"}:
|
|
15
|
+
return None
|
|
16
|
+
rows = [str(row or "") for row in rows]
|
|
17
|
+
choices = (pending or {}).get("choices") or []
|
|
18
|
+
if not isinstance(choices, list):
|
|
19
|
+
choices = []
|
|
20
|
+
has_yes = any(_choice_is_yes_proceed(choice) for choice in choices if isinstance(choice, dict))
|
|
21
|
+
raw_yes = any(_RAW_YES.search(row) for row in rows)
|
|
22
|
+
if not has_yes:
|
|
23
|
+
has_yes = raw_yes
|
|
24
|
+
if not has_yes:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
header = any(_APPROVAL_HEADER.search(row) for row in rows)
|
|
28
|
+
command = _extract_command(rows)
|
|
29
|
+
patch_hint = any(_PATCH_HINT.search(row) for row in rows)
|
|
30
|
+
if not header and not command and not patch_hint:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
summary = command or _approval_summary(rows, pending) or "codex approval"
|
|
34
|
+
return {
|
|
35
|
+
"command": command,
|
|
36
|
+
"summary": summary[:300],
|
|
37
|
+
"kind": "codex_exec_approval",
|
|
38
|
+
"choices": choices,
|
|
39
|
+
"dialog_key": screen_key,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _choice_is_yes_proceed(choice: dict[str, Any]) -> bool:
|
|
44
|
+
label = str(choice.get("label") or "").strip().lower()
|
|
45
|
+
choice_id = str(choice.get("id") or "").strip()
|
|
46
|
+
return label.startswith("yes") and ("proceed" in label or choice_id == "1")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _extract_command(rows: list[str]) -> str | None:
|
|
50
|
+
for row in rows:
|
|
51
|
+
match = _CMD_LINE.match(row or "")
|
|
52
|
+
if match:
|
|
53
|
+
command = match.group("cmd").strip()
|
|
54
|
+
if command:
|
|
55
|
+
return command[:300]
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _approval_summary(rows: list[str], pending: dict[str, Any] | None) -> str | None:
|
|
60
|
+
prompt = str((pending or {}).get("prompt") or "").strip()
|
|
61
|
+
if prompt:
|
|
62
|
+
return prompt[:300]
|
|
63
|
+
for row in rows:
|
|
64
|
+
text = str(row or "").strip()
|
|
65
|
+
if not text:
|
|
66
|
+
continue
|
|
67
|
+
if _APPROVAL_HEADER.search(text) or _PATCH_HINT.search(text):
|
|
68
|
+
return text[:300]
|
|
69
|
+
return None
|
|
@@ -171,6 +171,10 @@ class DeviceRegistry:
|
|
|
171
171
|
"device_display_name": "ALTER TABLE devices ADD COLUMN device_display_name TEXT",
|
|
172
172
|
"superseded_by_device_id": "ALTER TABLE devices ADD COLUMN superseded_by_device_id TEXT",
|
|
173
173
|
"proof_secret": "ALTER TABLE devices ADD COLUMN proof_secret TEXT",
|
|
174
|
+
# WS4: base64 X9.63 (uncompressed P-256 point) of the device's
|
|
175
|
+
# Secure-Enclave public key, registered at first pair. Used to
|
|
176
|
+
# verify zero-interaction re-pair challenge signatures.
|
|
177
|
+
"se_public_key_der": "ALTER TABLE devices ADD COLUMN se_public_key_der TEXT",
|
|
174
178
|
}
|
|
175
179
|
for column, statement in additive_columns.items():
|
|
176
180
|
if column not in existing:
|
|
@@ -422,6 +426,37 @@ class DeviceRegistry:
|
|
|
422
426
|
)
|
|
423
427
|
return token
|
|
424
428
|
|
|
429
|
+
def register_se_pubkey(self, device_id: str, se_public_key_der: str) -> bool:
|
|
430
|
+
"""WS4: store the device's Secure-Enclave public key (base64 X9.63)."""
|
|
431
|
+
if not se_public_key_der:
|
|
432
|
+
return False
|
|
433
|
+
with self.connect() as conn:
|
|
434
|
+
cur = conn.execute(
|
|
435
|
+
"UPDATE devices SET se_public_key_der = ? WHERE device_id = ?",
|
|
436
|
+
(se_public_key_der, device_id),
|
|
437
|
+
)
|
|
438
|
+
ok = cur.rowcount > 0
|
|
439
|
+
self.record_audit(
|
|
440
|
+
"device.register_se_pubkey",
|
|
441
|
+
device_id=device_id,
|
|
442
|
+
outcome="ok" if ok else "not_found",
|
|
443
|
+
conn=conn,
|
|
444
|
+
)
|
|
445
|
+
return ok
|
|
446
|
+
|
|
447
|
+
def get_se_pubkey(self, device_id: str) -> str | None:
|
|
448
|
+
"""The registered SE public key for an ACTIVE device. Revoked devices
|
|
449
|
+
return None, so revocation also blocks zero-interaction re-pair."""
|
|
450
|
+
with self.connect() as conn:
|
|
451
|
+
row = conn.execute(
|
|
452
|
+
"SELECT se_public_key_der FROM devices WHERE device_id = ? AND revoked_at IS NULL",
|
|
453
|
+
(device_id,),
|
|
454
|
+
).fetchone()
|
|
455
|
+
if row is None:
|
|
456
|
+
return None
|
|
457
|
+
value = row["se_public_key_der"]
|
|
458
|
+
return value if value else None
|
|
459
|
+
|
|
425
460
|
def record_audit(
|
|
426
461
|
self,
|
|
427
462
|
event: str,
|