pairling 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. package/payload-manifest.json +255 -0
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Request-bound HMAC proof verification for mutating Pairling endpoints."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+ INSTALL_ID_HEADER = "Pairling-Install-ID"
14
+ REQUEST_ID_HEADER = "Pairling-Request-ID"
15
+ TIMESTAMP_HEADER = "Pairling-Timestamp"
16
+ BODY_SHA256_HEADER = "Pairling-Body-SHA256"
17
+ PROOF_HEADER = "Pairling-Proof"
18
+ SKEW_MS = 10 * 60 * 1000
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ProofVerificationResult:
23
+ ok: bool
24
+ status: int = 200
25
+ code: str = "ok"
26
+ message: str = "ok"
27
+
28
+
29
+ class ReplayCache:
30
+ def __init__(self, *, retention_seconds: int = 600, max_entries: int = 4096):
31
+ self.retention_seconds = retention_seconds
32
+ self.max_entries = max_entries
33
+ self._seen: dict[tuple[str, str], float] = {}
34
+
35
+ def check_and_store(self, *, device_id: str, request_id: str, now: float | None = None) -> bool:
36
+ current = time.time() if now is None else now
37
+ cutoff = current - self.retention_seconds
38
+ if len(self._seen) > self.max_entries:
39
+ self._seen = {
40
+ key: ts for key, ts in self._seen.items()
41
+ if ts >= cutoff
42
+ }
43
+ key = (device_id, request_id)
44
+ if key in self._seen and self._seen[key] >= cutoff:
45
+ return False
46
+ self._seen[key] = current
47
+ return True
48
+
49
+
50
+ def body_sha256_hex(body: bytes) -> str:
51
+ return hashlib.sha256(body).hexdigest()
52
+
53
+
54
+ def canonical_request(
55
+ *,
56
+ method: str,
57
+ path_and_query: str,
58
+ timestamp_ms: str,
59
+ request_id: str,
60
+ body_sha256: str,
61
+ install_id: str,
62
+ device_id: str,
63
+ ) -> str:
64
+ return "\n".join([
65
+ method.upper(),
66
+ path_and_query,
67
+ timestamp_ms,
68
+ request_id,
69
+ body_sha256,
70
+ install_id,
71
+ device_id,
72
+ ])
73
+
74
+
75
+ def proof_hex(*, secret: str, canonical: str) -> str:
76
+ return hmac.new(
77
+ secret.encode("utf-8"),
78
+ canonical.encode("utf-8"),
79
+ hashlib.sha256,
80
+ ).hexdigest()
81
+
82
+
83
+ def verify_request_proof(
84
+ *,
85
+ headers: Any,
86
+ method: str,
87
+ path_and_query: str,
88
+ body: bytes,
89
+ auth_result: Any,
90
+ local_install_id: str,
91
+ replay_cache: ReplayCache,
92
+ now_ms: int | None = None,
93
+ ) -> ProofVerificationResult:
94
+ proof_secret = str(getattr(auth_result, "proof_secret", "") or "").strip()
95
+ if not proof_secret:
96
+ return ProofVerificationResult(False, 403, "missing_proof_secret", "Pair this Mac again to enable request proof.")
97
+
98
+ install_id = _header(headers, INSTALL_ID_HEADER)
99
+ request_id = _header(headers, REQUEST_ID_HEADER)
100
+ timestamp_ms = _header(headers, TIMESTAMP_HEADER)
101
+ body_hash = _header(headers, BODY_SHA256_HEADER)
102
+ proof = _header(headers, PROOF_HEADER)
103
+
104
+ if not install_id or not request_id or not timestamp_ms or not body_hash or not proof:
105
+ return ProofVerificationResult(False, 401, "missing_proof", "Request proof headers are required.")
106
+ if install_id != local_install_id:
107
+ return ProofVerificationResult(False, 403, "install_id_mismatch", "Request proof was for a different Mac.")
108
+ try:
109
+ parsed_ts = int(timestamp_ms)
110
+ except ValueError:
111
+ return ProofVerificationResult(False, 401, "bad_timestamp", "Request proof timestamp is invalid.")
112
+ current_ms = int(time.time() * 1000) if now_ms is None else now_ms
113
+ if abs(current_ms - parsed_ts) > SKEW_MS:
114
+ return ProofVerificationResult(False, 401, "stale_timestamp", "Request proof timestamp is stale.")
115
+
116
+ expected_body_hash = body_sha256_hex(body)
117
+ if not hmac.compare_digest(body_hash.lower(), expected_body_hash):
118
+ return ProofVerificationResult(False, 401, "body_hash_mismatch", "Request body hash does not match.")
119
+
120
+ device_id = str(getattr(auth_result, "device_id", "") or "")
121
+ canonical = canonical_request(
122
+ method=method,
123
+ path_and_query=path_and_query,
124
+ timestamp_ms=timestamp_ms,
125
+ request_id=request_id,
126
+ body_sha256=expected_body_hash,
127
+ install_id=install_id,
128
+ device_id=device_id,
129
+ )
130
+ expected_proof = proof_hex(secret=proof_secret, canonical=canonical)
131
+ if not hmac.compare_digest(proof.lower(), expected_proof):
132
+ return ProofVerificationResult(False, 401, "bad_proof", "Request proof did not verify.")
133
+ if not replay_cache.check_and_store(device_id=device_id, request_id=request_id, now=current_ms / 1000):
134
+ return ProofVerificationResult(False, 409, "replayed_request", "Request proof was already used.")
135
+ return ProofVerificationResult(True)
136
+
137
+
138
+ def _header(headers: Any, name: str) -> str:
139
+ try:
140
+ return str(headers.get(name, "") or "").strip()
141
+ except AttributeError:
142
+ return str((headers or {}).get(name, "") or "").strip()
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """Shared constants for the Pairling Mac runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ SCHEMA_VERSION = 1
9
+ POWER_STATE_SCHEMA_VERSION = 1
10
+ RUNTIME_NAME = "pairling-mac-runtime"
11
+ CONTRACT_VERSION = "pairling-runtime-v1"
12
+ PAIRLING_CONTRACT_VERSION = CONTRACT_VERSION
13
+ COMPAT_MODE = "pairling-v1"
14
+ PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
15
+ LEGACY_PORT = 7723
16
+ DAEMON_LABEL = "dev.pairling.companiond"
17
+ GUARDIAN_LABEL = "dev.pairling.power-guardian"
18
+ LEGACY_DAEMON_LABEL = "com.mghome.notify-webhook"
19
+ LEGACY_GUARDIAN_LABEL = "com.mghome.companion-power-guardian"
20
+ LEGACY_TOKEN_RELATIVE_PATH = ".claude/scripts/.notify-token"
21
+ POWER_STATE_PATH = "/var/run/pairling-power-state.json"
22
+ TAILSCALE_VARIANT = "standalone"
23
+ AUTH_MODE = "scoped-device-bearer"
24
+ PAIR_SERVICE_TYPE = "_pairling-pair._tcp"
25
+ RUNTIME_BONJOUR_ADVERTISED = False
26
+
27
+ DEFAULT_DEVICE_SCOPES = frozenset({
28
+ "health:read",
29
+ "manifest:read",
30
+ "sessions:read",
31
+ "transcript:read",
32
+ "session:send",
33
+ "session:spawn",
34
+ "session:signal",
35
+ "worker:read",
36
+ "worker:control",
37
+ "llm:route",
38
+ "pairling-tools:run",
39
+ "files:upload",
40
+ "files:read",
41
+ "files:write",
42
+ "files:delete",
43
+ "pair:admin",
44
+ "phone-tools:reverse",
45
+ })
46
+
47
+ SUPPORTED_CONTRACTS = {CONTRACT_VERSION}
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ """Runtime manifest loading and verification for the Mac companion daemon."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from runtime_contract import (
14
+ AUTH_MODE,
15
+ COMPAT_MODE,
16
+ CONTRACT_VERSION,
17
+ DAEMON_LABEL,
18
+ PAIR_SERVICE_TYPE,
19
+ PORT,
20
+ RUNTIME_BONJOUR_ADVERTISED,
21
+ RUNTIME_NAME,
22
+ TAILSCALE_VARIANT,
23
+ )
24
+ from runtime_paths import release_root_for
25
+
26
+
27
+ def sha256_file(path: Path) -> str:
28
+ digest = hashlib.sha256()
29
+ with path.open("rb") as fh:
30
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
31
+ digest.update(chunk)
32
+ return digest.hexdigest()
33
+
34
+
35
+ def utc_now_iso() -> str:
36
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
37
+
38
+
39
+ def load_manifest_for(script_path: str | Path) -> tuple[dict[str, Any] | None, Path | None, str | None]:
40
+ root = release_root_for(script_path)
41
+ if root is None:
42
+ return None, None, "manifest not found for script path"
43
+ path = root / "manifest.json"
44
+ try:
45
+ data = json.loads(path.read_text())
46
+ except Exception as exc:
47
+ return None, path, f"{type(exc).__name__}: {exc}"
48
+ if not isinstance(data, dict):
49
+ return None, path, "manifest root is not an object"
50
+ return data, path, None
51
+
52
+
53
+ def _manifest_file_hash(manifest: dict[str, Any], relative_path: str) -> str | None:
54
+ for item in manifest.get("files") or []:
55
+ if isinstance(item, dict) and item.get("path") == relative_path:
56
+ value = item.get("sha256")
57
+ return value if isinstance(value, str) else None
58
+ return None
59
+
60
+
61
+ def build_runtime_info(
62
+ script_path: str | Path,
63
+ *,
64
+ relative_path: str = "companiond/pairlingd.py",
65
+ launchd_label: str = DAEMON_LABEL,
66
+ ) -> dict[str, Any]:
67
+ script = Path(script_path).resolve()
68
+ manifest, manifest_path, manifest_error = load_manifest_for(script)
69
+ runtime_version = os.environ.get("COMPANION_RUNTIME_VERSION", "legacy")
70
+ source_revision = os.environ.get("COMPANION_SOURCE_REVISION", "unknown")
71
+ source_branch = os.environ.get("COMPANION_SOURCE_BRANCH", "unknown")
72
+ source_dirty = None
73
+ installed_at = os.environ.get("COMPANION_INSTALLED_AT")
74
+ install_root = str(script.parent.parent) if script.parent.name in {"companiond", "guardian"} else str(script.parent)
75
+ source_hash = None
76
+ verified = False
77
+ verification_error = manifest_error
78
+
79
+ try:
80
+ source_hash = sha256_file(script)
81
+ except Exception as exc:
82
+ verification_error = f"{type(exc).__name__}: {exc}"
83
+
84
+ if manifest:
85
+ runtime_version = str(manifest.get("runtime_version") or runtime_version)
86
+ source_revision = str(manifest.get("source_revision") or source_revision)
87
+ source_branch = str(manifest.get("source_branch") or source_branch)
88
+ if "source_dirty" in manifest:
89
+ source_dirty = bool(manifest.get("source_dirty"))
90
+ installed_at = str(manifest.get("installed_at") or installed_at or "")
91
+ install_root = str(manifest.get("install_root") or install_root)
92
+ expected_hash = _manifest_file_hash(manifest, relative_path)
93
+ if expected_hash and source_hash:
94
+ verified = expected_hash == source_hash
95
+ if not verified:
96
+ verification_error = f"hash mismatch for {relative_path}"
97
+ else:
98
+ verification_error = f"manifest missing hash for {relative_path}"
99
+
100
+ return {
101
+ "name": RUNTIME_NAME,
102
+ "runtime_version": runtime_version,
103
+ "contract_version": CONTRACT_VERSION,
104
+ "source_revision": source_revision,
105
+ "source_branch": source_branch,
106
+ "source_dirty": source_dirty,
107
+ "installed_at": installed_at or None,
108
+ "install_root": install_root,
109
+ "compat_mode": COMPAT_MODE,
110
+ "launchd_label": launchd_label,
111
+ "port": PORT,
112
+ "tailscale_variant": TAILSCALE_VARIANT,
113
+ "verified": verified,
114
+ "source_hash": source_hash,
115
+ "manifest_path": str(manifest_path) if manifest_path else None,
116
+ "manifest_error": verification_error,
117
+ }
118
+
119
+
120
+ def public_runtime_info(info: dict[str, Any]) -> dict[str, Any]:
121
+ """Return the unauthenticated-safe subset of runtime metadata."""
122
+ return {
123
+ "name": info.get("name") or RUNTIME_NAME,
124
+ "runtime_version": info.get("runtime_version"),
125
+ "contract_version": info.get("contract_version") or CONTRACT_VERSION,
126
+ "compat_mode": info.get("compat_mode") or COMPAT_MODE,
127
+ "launchd_label": info.get("launchd_label") or DAEMON_LABEL,
128
+ "port": info.get("port") or PORT,
129
+ "tailscale_variant": info.get("tailscale_variant") or TAILSCALE_VARIANT,
130
+ "verified": bool(info.get("verified")),
131
+ }
132
+
133
+
134
+ def build_manifest_payload(
135
+ runtime_info: dict[str, Any],
136
+ *,
137
+ authenticated: bool,
138
+ device_id: str | None = None,
139
+ scopes: list[str] | None = None,
140
+ ) -> dict[str, Any]:
141
+ payload: dict[str, Any] = {
142
+ "ok": True,
143
+ "schema_version": 1,
144
+ "contract_version": CONTRACT_VERSION,
145
+ "runtime": public_runtime_info(runtime_info),
146
+ "auth": {
147
+ "mode": AUTH_MODE,
148
+ "required": True,
149
+ "legacy_global_token": False,
150
+ "authenticated": authenticated,
151
+ },
152
+ "network": {
153
+ "runtime_port": PORT,
154
+ "pair_service_type": PAIR_SERVICE_TYPE,
155
+ "runtime_bonjour_advertised": RUNTIME_BONJOUR_ADVERTISED,
156
+ "route_diagnostics": {
157
+ "bonjour": {
158
+ "service_type": PAIR_SERVICE_TYPE,
159
+ "runtime_port": PORT,
160
+ "txt_version": "2",
161
+ },
162
+ "tailnet": {
163
+ "variant": TAILSCALE_VARIANT,
164
+ },
165
+ },
166
+ },
167
+ "endpoints": {
168
+ "public": ["/health", "/manifest", "/pair/start", "/pair/claim"],
169
+ "authenticated": [
170
+ "/manifest",
171
+ "/sessions",
172
+ "/sessions-stream",
173
+ "/session-live-events",
174
+ "/transcript",
175
+ "/transcript-stream",
176
+ "/send-text",
177
+ "/inject-now",
178
+ "/worker-kill",
179
+ "/pairling-tools/run",
180
+ "/phone-tools/availability",
181
+ "/phone-tools/next",
182
+ "/phone-tools/result",
183
+ "/sentinel/status",
184
+ "/sentinel/preferences",
185
+ "/sentinel/snooze",
186
+ "/sentinel/evaluate-now",
187
+ "/sentinel/events",
188
+ "/pair/revoke",
189
+ "/pair/rotate-token",
190
+ ],
191
+ },
192
+ }
193
+ if authenticated:
194
+ payload["runtime"] = runtime_info
195
+ payload["auth"]["device_id"] = device_id
196
+ payload["auth"]["scopes"] = sorted(scopes or [])
197
+ return payload
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """Path resolver for the Pairling runtime."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from runtime_contract import LEGACY_TOKEN_RELATIVE_PATH, POWER_STATE_PATH
10
+
11
+
12
+ def home() -> Path:
13
+ return Path.home()
14
+
15
+
16
+ def app_support_root() -> Path:
17
+ return Path(
18
+ os.environ.get(
19
+ "PAIRLING_APP_SUPPORT_ROOT",
20
+ os.environ.get(
21
+ "COMPANION_APP_SUPPORT_ROOT",
22
+ str(home() / "Library" / "Application Support" / "Pairling"),
23
+ ),
24
+ )
25
+ )
26
+
27
+
28
+ def logs_root() -> Path:
29
+ return Path(
30
+ os.environ.get(
31
+ "PAIRLING_LOGS_ROOT",
32
+ os.environ.get(
33
+ "COMPANION_LOGS_ROOT",
34
+ str(home() / "Library" / "Logs" / "Pairling"),
35
+ ),
36
+ )
37
+ )
38
+
39
+
40
+ def runtime_root() -> Path:
41
+ return app_support_root() / "runtime"
42
+
43
+
44
+ def current_release() -> Path:
45
+ return runtime_root() / "current"
46
+
47
+
48
+ def state_root() -> Path:
49
+ return app_support_root() / "state"
50
+
51
+
52
+ def install_history_path() -> Path:
53
+ return state_root() / "install-history.jsonl"
54
+
55
+
56
+ def install_id_path() -> Path:
57
+ return state_root() / "install-id"
58
+
59
+
60
+ def devices_db_path() -> Path:
61
+ return app_support_root() / "devices.sqlite"
62
+
63
+
64
+ def audit_log_path() -> Path:
65
+ return logs_root() / "audit.jsonl"
66
+
67
+
68
+ def token_path() -> Path:
69
+ return Path(os.environ.get("NOTIFY_TOKEN_FILE", str(home() / LEGACY_TOKEN_RELATIVE_PATH)))
70
+
71
+
72
+ def guardian_state_path() -> Path:
73
+ return Path(os.environ.get("COMPANION_POWER_STATE_PATH", POWER_STATE_PATH))
74
+
75
+
76
+ def legacy_scripts_root() -> Path:
77
+ return home() / ".claude" / "scripts"
78
+
79
+
80
+ def release_root_for(script_path: str | Path) -> Path | None:
81
+ path = Path(script_path).resolve()
82
+ parent = path.parent
83
+ if parent.name in {"companiond", "guardian"}:
84
+ root = parent.parent
85
+ if (root / "manifest.json").is_file():
86
+ return root
87
+ return None