pairling 0.2.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.
Files changed (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/payload/mac/SOURCE_BRANCH +1 -1
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/app_attest_lan.py +87 -0
  7. package/payload/mac/companiond/codex_approval.py +69 -0
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +374 -70
  10. package/payload/mac/companiond/pairling_psk.py +100 -0
  11. package/payload/mac/companiond/pairling_tools.py +2 -2
  12. package/payload/mac/companiond/pairlingd.py +977 -104
  13. package/payload/mac/companiond/pty_broker.py +441 -3
  14. package/payload/mac/companiond/pty_broker_client.py +167 -0
  15. package/payload/mac/companiond/pty_broker_service.py +84 -0
  16. package/payload/mac/companiond/runtime_contract.py +0 -2
  17. package/payload/mac/companiond/standard_push_publisher.py +7 -0
  18. package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
  19. package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
  22. package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
  23. package/payload/mac/connectd/internal/status/status.go +9 -0
  24. package/payload/mac/install/doctor.sh +160 -18
  25. package/payload/mac/install/install-runtime.sh +329 -12
  26. package/payload/mac/install/psk_dependency_check.py +40 -0
  27. package/payload/mac/install/render-launchd.py +23 -0
  28. package/payload/mac/install/uninstall-runtime.sh +4 -12
  29. package/payload-manifest.json +51 -23
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import fcntl
4
+ import base64
4
5
  import hashlib
5
6
  import json
6
7
  import os
@@ -10,6 +11,7 @@ import select
10
11
  import shlex
11
12
  import signal
12
13
  import socket
14
+ import secrets
13
15
  import struct
14
16
  import subprocess
15
17
  import termios
@@ -18,6 +20,7 @@ import time
18
20
  import unicodedata
19
21
  from dataclasses import dataclass, field
20
22
  from pathlib import Path
23
+ from typing import Any
21
24
 
22
25
  from terminal_text_sanitizer import TERMINAL_TEXT_MAX_CHARS, sanitize_terminal_text_input
23
26
  from terminal_screen_backend import create_terminal_screen_backend, detect_terminal_pending_input
@@ -41,6 +44,244 @@ _ABSOLUTE_PATH_ROOT_TOKENS = {
41
44
  "var",
42
45
  }
43
46
  _ANSI_COLOR_NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
47
+ _RPC_MAX_FRAME_BYTES = 8 * 1024 * 1024
48
+ _TERMINAL_SURFACE_V2_NONCE_SALT = os.urandom(16).hex()
49
+ BROKER_PROTOCOL_VERSION = 1
50
+ BROKER_CODE_VERSION = "pty-broker-v1"
51
+
52
+
53
+ def _read_broker_source_revision(runtime_root: Path | None) -> str | None:
54
+ if runtime_root is None:
55
+ return None
56
+ candidates = [
57
+ runtime_root / "manifest.json",
58
+ runtime_root / "mac" / "SOURCE_REVISION",
59
+ runtime_root / "SOURCE_REVISION",
60
+ ]
61
+ for path in candidates:
62
+ try:
63
+ if path.name == "manifest.json":
64
+ payload = json.loads(path.read_text(encoding="utf-8"))
65
+ revision = payload.get("source_revision")
66
+ return str(revision) if revision else None
67
+ revision = path.read_text(encoding="utf-8").strip()
68
+ return revision or None
69
+ except FileNotFoundError:
70
+ continue
71
+ except Exception:
72
+ continue
73
+ return None
74
+
75
+
76
+ def _file_sha256(path: Path) -> str | None:
77
+ try:
78
+ digest = hashlib.sha256()
79
+ with path.open("rb") as fh:
80
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
81
+ digest.update(chunk)
82
+ return digest.hexdigest()
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def ensure_pty_broker_token(companion_dir: Path) -> str:
88
+ token_path = companion_dir / "pty-broker-token"
89
+ try:
90
+ companion_dir.mkdir(parents=True, exist_ok=True)
91
+ if token_path.exists():
92
+ token = token_path.read_text(encoding="utf-8").strip()
93
+ if re.fullmatch(r"[0-9a-f]{64}", token):
94
+ try:
95
+ os.chmod(token_path, 0o600)
96
+ except OSError:
97
+ pass
98
+ return token
99
+ token = secrets.token_hex(32)
100
+ tmp = token_path.with_name(token_path.name + f".tmp.{os.getpid()}")
101
+ with open(tmp, "w", encoding="utf-8") as fh:
102
+ fh.write(token + "\n")
103
+ fh.flush()
104
+ os.fsync(fh.fileno())
105
+ os.chmod(tmp, 0o600)
106
+ os.replace(tmp, token_path)
107
+ return token
108
+ except OSError:
109
+ # Fallback keeps the broker functional for test fixtures; production
110
+ # launchd and the daemon both use the file-backed path.
111
+ return secrets.token_hex(32)
112
+
113
+
114
+ def _sha256_prefixed(material: dict) -> str:
115
+ return "sha256:" + hashlib.sha256(
116
+ json.dumps(material, sort_keys=True, separators=(",", ":")).encode("utf-8")
117
+ ).hexdigest()
118
+
119
+
120
+ def _parse_session_ref(session_id: str) -> tuple[str, str]:
121
+ if ":" in session_id:
122
+ provider, native_id = session_id.split(":", 1)
123
+ return provider, native_id
124
+ return "claude", session_id
125
+
126
+
127
+ def _terminal_surface_v2_cell_payload(cell) -> dict:
128
+ payload = {"text": str(getattr(cell, "text", ""))}
129
+ width = int(getattr(cell, "width", 1) or 1)
130
+ fg = str(getattr(cell, "fg", "default") or "default")
131
+ bg = str(getattr(cell, "bg", "default") or "default")
132
+ bold = bool(getattr(cell, "bold", False))
133
+ italic = bool(getattr(cell, "italic", False))
134
+ underline = bool(getattr(cell, "underline", False))
135
+ inverse = bool(getattr(cell, "inverse", False))
136
+ link_id = getattr(cell, "link_id", None)
137
+ if width != 1:
138
+ payload["width"] = width
139
+ if fg != "default":
140
+ payload["fg"] = fg
141
+ if bg != "default":
142
+ payload["bg"] = bg
143
+ if bold:
144
+ payload["bold"] = True
145
+ if italic:
146
+ payload["italic"] = True
147
+ if underline:
148
+ payload["underline"] = True
149
+ if inverse:
150
+ payload["inverse"] = True
151
+ if link_id is not None:
152
+ payload["link_id"] = link_id
153
+ return payload
154
+
155
+
156
+ def terminal_surface_v2_payload_from_state(session_id: str, state) -> dict:
157
+ provider, native_id = _parse_session_ref(session_id)
158
+ row_payloads: list[dict] = []
159
+ links_payload: dict[str, dict] = {}
160
+ for link_id, link_value in (getattr(state, "links", None) or {}).items():
161
+ if isinstance(link_value, dict):
162
+ url = link_value.get("url")
163
+ label = link_value.get("label")
164
+ else:
165
+ url = str(link_value)
166
+ label = None
167
+ links_payload[str(link_id)] = {
168
+ "url": str(url) if url is not None else None,
169
+ "label": str(label) if label is not None else None,
170
+ }
171
+ for row in getattr(state, "visible_rows", ()):
172
+ cells = [_terminal_surface_v2_cell_payload(cell) for cell in getattr(row, "cells", ())]
173
+ row_material = {
174
+ "index": int(getattr(row, "index", 0)),
175
+ "wrapped": bool(getattr(row, "wrapped", False)),
176
+ "cells": cells,
177
+ }
178
+ row_payloads.append({
179
+ "index": row_material["index"],
180
+ "wrapped": row_material["wrapped"],
181
+ "dirty_generation": int(getattr(row, "dirty_generation", 0) or 0),
182
+ "cells_hash": _sha256_prefixed(row_material),
183
+ "cells": cells,
184
+ })
185
+ cursor = getattr(state, "cursor", None)
186
+ cursor_payload = {
187
+ "row": getattr(cursor, "row", None),
188
+ "column": getattr(cursor, "column", None),
189
+ "visible": bool(getattr(cursor, "visible", True)),
190
+ "style": str(getattr(cursor, "style", "block") or "block"),
191
+ }
192
+ dimensions = {
193
+ "rows": int(getattr(state, "rows", 0) or 0),
194
+ "columns": int(getattr(state, "columns", 0) or 0),
195
+ }
196
+ capabilities = list(getattr(state, "capabilities", ()) or ())
197
+ scrollback = {
198
+ "window_start": 0,
199
+ "window_size": len(row_payloads),
200
+ "total_rows": len(row_payloads),
201
+ "truncated_before": False,
202
+ }
203
+ pending_input = getattr(state, "pending_input", None)
204
+ pending_input_detection = getattr(state, "pending_input_detection", None)
205
+ if pending_input_detection is None:
206
+ pending_input_detection = {
207
+ "status": "unknown",
208
+ "parser_version": None,
209
+ "surface": "v2",
210
+ "confidence": None,
211
+ "reason": "detection_metadata_missing",
212
+ }
213
+ pending_input_state = "present" if isinstance(pending_input, dict) else (
214
+ "none" if pending_input_detection.get("status") == "ran" else "unknown"
215
+ )
216
+ hash_material = {
217
+ "schema_version": 2,
218
+ "session_id": session_id,
219
+ "provider": provider,
220
+ "native_id": native_id,
221
+ "source": getattr(state, "source", "broker_vt"),
222
+ "backend": getattr(state, "backend", "pty_broker"),
223
+ "capabilities": capabilities,
224
+ "degraded_reason": getattr(state, "degraded_reason", None),
225
+ "generation": int(getattr(state, "generation", 0) or 0),
226
+ "raw_offset": int(getattr(state, "raw_offset", 0) or 0),
227
+ "dimensions": dimensions,
228
+ "title": getattr(state, "title", None),
229
+ "alternate_screen": bool(getattr(state, "alternate_screen", False)),
230
+ "cursor": cursor_payload,
231
+ "scrollback": scrollback,
232
+ "rows": row_payloads,
233
+ "links": links_payload,
234
+ "pending_input": pending_input,
235
+ "pending_input_state": pending_input_state,
236
+ "pending_input_detection": pending_input_detection,
237
+ }
238
+ screen_hash = _sha256_prefixed(hash_material)
239
+ nonce = _sha256_prefixed({
240
+ "screen_hash": screen_hash,
241
+ "generation": hash_material["generation"],
242
+ "raw_offset": hash_material["raw_offset"],
243
+ "server_salt": _TERMINAL_SURFACE_V2_NONCE_SALT,
244
+ })
245
+ return {
246
+ **hash_material,
247
+ "screen_hash": screen_hash,
248
+ "nonce": nonce,
249
+ "changed_at": time.time(),
250
+ "event_limits": {
251
+ "max_event_bytes": 64 * 1024,
252
+ "truncated": False,
253
+ "truncation_reason": None,
254
+ },
255
+ }
256
+
257
+
258
+ def _read_exact(conn: socket.socket, count: int) -> bytes:
259
+ chunks: list[bytes] = []
260
+ remaining = count
261
+ while remaining > 0:
262
+ chunk = conn.recv(remaining)
263
+ if not chunk:
264
+ raise EOFError("socket closed while reading frame")
265
+ chunks.append(chunk)
266
+ remaining -= len(chunk)
267
+ return b"".join(chunks)
268
+
269
+
270
+ def _read_rpc_frame(conn: socket.socket) -> dict[str, Any]:
271
+ header = _read_exact(conn, 4)
272
+ length = struct.unpack(">I", header)[0]
273
+ if length <= 0 or length > _RPC_MAX_FRAME_BYTES:
274
+ raise ValueError("invalid RPC frame length")
275
+ payload = _read_exact(conn, length)
276
+ value = json.loads(payload.decode("utf-8"))
277
+ if not isinstance(value, dict):
278
+ raise ValueError("RPC frame must be a JSON object")
279
+ return value
280
+
281
+
282
+ def _write_rpc_frame(conn: socket.socket, payload: dict[str, Any]) -> None:
283
+ data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
284
+ conn.sendall(struct.pack(">I", len(data)) + data)
44
285
 
45
286
 
46
287
  def _is_direct_slash_invocation_text(text: str) -> bool:
@@ -752,9 +993,24 @@ class PTYBrokerSession:
752
993
 
753
994
 
754
995
  class PTYBrokerManager:
755
- def __init__(self, socket_path: Path, log_dir: Path) -> None:
996
+ def __init__(
997
+ self,
998
+ socket_path: Path,
999
+ log_dir: Path,
1000
+ token: str | None = None,
1001
+ *,
1002
+ runtime_root: Path | None = None,
1003
+ script_path: Path | None = None,
1004
+ source_revision: str | None = None,
1005
+ started_at: float | None = None,
1006
+ ) -> None:
756
1007
  self.socket_path = socket_path
757
1008
  self.log_dir = log_dir
1009
+ self.token = token or secrets.token_hex(32)
1010
+ self.script_path = Path(script_path or __file__).absolute()
1011
+ self.runtime_root = Path(runtime_root).absolute() if runtime_root is not None else self.script_path.parent.parent
1012
+ self.source_revision = source_revision if source_revision is not None else _read_broker_source_revision(self.runtime_root)
1013
+ self.started_at = float(started_at or time.time())
758
1014
  self._sessions: dict[str, PTYBrokerSession] = {}
759
1015
  self._by_tty: dict[str, str] = {}
760
1016
  self._lock = threading.RLock()
@@ -805,6 +1061,20 @@ class PTYBrokerManager:
805
1061
  self._by_tty[session.slave_tty] = session_id
806
1062
  return session
807
1063
 
1064
+ def descriptor(self, session: PTYBrokerSession) -> dict:
1065
+ return {
1066
+ "session_id": session.session_id,
1067
+ "provider": session.provider,
1068
+ "native_id": session.native_id,
1069
+ "project": session.project,
1070
+ "slave_tty": session.slave_tty,
1071
+ "pid": session.pid,
1072
+ "raw_log_path": str(session.raw_log_path) if session.raw_log_path else None,
1073
+ "generation": session.generation,
1074
+ "started_at": session.started_at,
1075
+ "alive": session.is_alive(),
1076
+ }
1077
+
808
1078
  def get(self, session_id: str) -> PTYBrokerSession | None:
809
1079
  with self._lock:
810
1080
  return self._sessions.get(session_id)
@@ -814,16 +1084,74 @@ class PTYBrokerManager:
814
1084
  sid = self._by_tty.get(tty_path)
815
1085
  return self._sessions.get(sid or "")
816
1086
 
817
- def register_alias(self, alias_session_id: str, session: PTYBrokerSession) -> None:
1087
+ def register_alias(self, alias_session_id: str, session: PTYBrokerSession | str) -> None:
1088
+ if isinstance(session, str):
1089
+ resolved = self.get(session)
1090
+ if resolved is None:
1091
+ return
1092
+ session = resolved
818
1093
  with self._lock:
819
1094
  self._sessions[alias_session_id] = session
820
1095
 
1096
+ def list_sessions(self) -> list[dict]:
1097
+ out: list[dict] = []
1098
+ with self._lock:
1099
+ seen: set[int] = set()
1100
+ for session in self._sessions.values():
1101
+ ident = id(session)
1102
+ if ident in seen:
1103
+ continue
1104
+ seen.add(ident)
1105
+ if session.is_alive():
1106
+ out.append(self.descriptor(session))
1107
+ return out
1108
+
1109
+ def live_sessions(self) -> list[dict]:
1110
+ return [
1111
+ {
1112
+ "broker_id": item["session_id"],
1113
+ "provider": item["provider"],
1114
+ "native_id": item["native_id"],
1115
+ "slave_tty": item["slave_tty"],
1116
+ "pid": item["pid"],
1117
+ }
1118
+ for item in self.list_sessions()
1119
+ ]
1120
+
1121
+ def status(self) -> dict:
1122
+ live_sessions = self.list_sessions()
1123
+ script_stat = None
1124
+ try:
1125
+ script_stat = self.script_path.stat()
1126
+ except OSError:
1127
+ pass
1128
+ return {
1129
+ "schema_version": 1,
1130
+ "protocol_version": BROKER_PROTOCOL_VERSION,
1131
+ "code_version": BROKER_CODE_VERSION,
1132
+ "pid": os.getpid(),
1133
+ "started_at": self.started_at,
1134
+ "socket_path": str(self.socket_path),
1135
+ "runtime_root": str(self.runtime_root),
1136
+ "script_path": str(self.script_path),
1137
+ "script_mtime": script_stat.st_mtime if script_stat is not None else None,
1138
+ "script_sha256": _file_sha256(self.script_path),
1139
+ "source_revision": self.source_revision,
1140
+ "live_session_count": len(live_sessions),
1141
+ }
1142
+
821
1143
  def snapshot(self, session_id: str, public_session_id: str | None = None) -> dict | None:
822
1144
  session = self.get(session_id)
823
1145
  if not session:
824
1146
  return None
825
1147
  return session.snapshot(public_session_id=public_session_id or session_id)
826
1148
 
1149
+ def snapshot_v2(self, session_id: str, public_session_id: str | None = None) -> dict | None:
1150
+ session = self.get(session_id)
1151
+ if not session:
1152
+ return None
1153
+ return terminal_surface_v2_payload_from_state(public_session_id or session_id, session.snapshot_v2())
1154
+
827
1155
  def control(self, session_id: str, action: dict) -> dict:
828
1156
  session = self.get(session_id)
829
1157
  if not session:
@@ -857,6 +1185,22 @@ class PTYBrokerManager:
857
1185
  return None
858
1186
  return session.raw_tail(since=since)
859
1187
 
1188
+ def _peer_uid_ok(self, conn: socket.socket) -> bool:
1189
+ try:
1190
+ if hasattr(os, "getpeereid"):
1191
+ uid, _gid = os.getpeereid(conn.fileno())
1192
+ return int(uid) == os.getuid()
1193
+ if hasattr(socket, "SO_PEERCRED"):
1194
+ data = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
1195
+ _pid, uid, _gid = struct.unpack("3i", data)
1196
+ return int(uid) == os.getuid()
1197
+ except Exception:
1198
+ return False
1199
+ return True
1200
+
1201
+ def _validate_token(self, value: object) -> bool:
1202
+ return isinstance(value, str) and secrets.compare_digest(value, self.token)
1203
+
860
1204
  def _serve_attach_socket(self) -> None:
861
1205
  server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
862
1206
  server.bind(str(self.socket_path))
@@ -864,10 +1208,98 @@ class PTYBrokerManager:
864
1208
  server.listen(8)
865
1209
  while True:
866
1210
  conn, _ = server.accept()
867
- threading.Thread(target=self._handle_attach_client, args=(conn,), daemon=True).start()
1211
+ threading.Thread(target=self._handle_socket_client, args=(conn,), daemon=True).start()
1212
+
1213
+ def _handle_socket_client(self, conn: socket.socket) -> None:
1214
+ try:
1215
+ first = conn.recv(1, socket.MSG_PEEK)
1216
+ except OSError:
1217
+ conn.close()
1218
+ return
1219
+ if first == b"{":
1220
+ self._handle_attach_client(conn)
1221
+ else:
1222
+ self._handle_rpc_client(conn)
1223
+
1224
+ def _handle_rpc_client(self, conn: socket.socket) -> None:
1225
+ with conn:
1226
+ try:
1227
+ if not self._peer_uid_ok(conn):
1228
+ _write_rpc_frame(conn, {"ok": False, "error": {"code": "unauthorized_peer", "message": "same-uid broker peer required"}})
1229
+ return
1230
+ request = _read_rpc_frame(conn)
1231
+ if not self._validate_token(request.get("token")):
1232
+ _write_rpc_frame(conn, {"ok": False, "error": {"code": "unauthorized", "message": "pty broker token required"}})
1233
+ return
1234
+ response = self._dispatch_rpc(request)
1235
+ except Exception as exc:
1236
+ response = {"ok": False, "error": {"code": type(exc).__name__, "message": str(exc)[:300]}}
1237
+ _write_rpc_frame(conn, response)
1238
+
1239
+ def _dispatch_rpc(self, request: dict[str, Any]) -> dict[str, Any]:
1240
+ op = str(request.get("op") or "")
1241
+ if op == "spawn":
1242
+ session = self.spawn(
1243
+ session_id=str(request.get("session_id") or ""),
1244
+ provider=str(request.get("provider") or ""),
1245
+ native_id=str(request.get("native_id") or ""),
1246
+ project=str(request.get("project") or ""),
1247
+ command=str(request.get("command") or ""),
1248
+ rows=int(request.get("rows") or 30),
1249
+ columns=int(request.get("columns") or 120),
1250
+ env=request.get("env") if isinstance(request.get("env"), dict) else None,
1251
+ )
1252
+ return {"ok": True, "session": self.descriptor(session)}
1253
+ if op == "get":
1254
+ session = self.get(str(request.get("session_id") or ""))
1255
+ return {"ok": True, "session": self.descriptor(session) if session else None}
1256
+ if op == "get_by_tty":
1257
+ session = self.get_by_tty(str(request.get("tty") or ""))
1258
+ return {"ok": True, "session": self.descriptor(session) if session else None}
1259
+ if op == "register_alias":
1260
+ self.register_alias(str(request.get("alias") or ""), str(request.get("session_id") or ""))
1261
+ return {"ok": True}
1262
+ if op == "snapshot":
1263
+ return {"ok": True, "snapshot": self.snapshot(
1264
+ str(request.get("session_id") or ""),
1265
+ public_session_id=str(request.get("public_session_id") or "") or None,
1266
+ )}
1267
+ if op == "snapshot_v2":
1268
+ return {"ok": True, "surface": self.snapshot_v2(
1269
+ str(request.get("session_id") or ""),
1270
+ public_session_id=str(request.get("public_session_id") or "") or None,
1271
+ )}
1272
+ if op == "raw_tail":
1273
+ tail = self.raw_tail(str(request.get("session_id") or ""), since=int(request.get("since") or 0))
1274
+ if tail is None:
1275
+ return {"ok": True, "tail": None}
1276
+ data, next_offset, total, reset = tail
1277
+ return {
1278
+ "ok": True,
1279
+ "tail": {
1280
+ "b64": base64.b64encode(data).decode("ascii"),
1281
+ "next_offset": next_offset,
1282
+ "total": total,
1283
+ "reset": reset,
1284
+ },
1285
+ }
1286
+ if op == "control":
1287
+ return {"ok": True, "result": self.control(str(request.get("session_id") or ""), request.get("action") if isinstance(request.get("action"), dict) else {})}
1288
+ if op == "send_text":
1289
+ return {"ok": True, "result": self.send_text(str(request.get("session_id") or ""), str(request.get("text") or ""))}
1290
+ if op == "terminate":
1291
+ return {"ok": True, "result": self.terminate(str(request.get("session_id") or ""), sig=int(request.get("sig") or signal.SIGTERM))}
1292
+ if op == "list_sessions":
1293
+ return {"ok": True, "sessions": self.list_sessions()}
1294
+ if op == "status":
1295
+ return {"ok": True, "status": self.status()}
1296
+ return {"ok": False, "error": {"code": "unknown_op", "message": f"unknown broker op: {op}"}}
868
1297
 
869
1298
  def _handle_attach_client(self, conn: socket.socket) -> None:
870
1299
  with conn:
1300
+ if not self._peer_uid_ok(conn):
1301
+ conn.sendall(b"pairling attach: same-uid broker peer required\n")
1302
+ return
871
1303
  line = b""
872
1304
  while not line.endswith(b"\n") and len(line) < 4096:
873
1305
  chunk = conn.recv(1)
@@ -879,6 +1311,12 @@ class PTYBrokerManager:
879
1311
  except Exception:
880
1312
  conn.sendall(b"pairling attach: bad hello\n")
881
1313
  return
1314
+ if str(hello.get("op") or "attach") != "attach":
1315
+ conn.sendall(b"pairling attach: bad operation\n")
1316
+ return
1317
+ if not self._validate_token(hello.get("token")):
1318
+ conn.sendall(b"pairling attach: broker token required; update Pairling runtime and retry\n")
1319
+ return
882
1320
  session_id = str(hello.get("session_id") or "").strip()
883
1321
  session = self.get(session_id)
884
1322
  if not session:
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import secrets
7
+ import signal
8
+ import socket
9
+ import struct
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ _RPC_MAX_FRAME_BYTES = 8 * 1024 * 1024
16
+
17
+
18
+ def ensure_pty_broker_token(companion_dir: Path) -> str:
19
+ token_path = companion_dir / "pty-broker-token"
20
+ try:
21
+ companion_dir.mkdir(parents=True, exist_ok=True)
22
+ if token_path.exists():
23
+ token = token_path.read_text(encoding="utf-8").strip()
24
+ if len(token) == 64 and all(ch in "0123456789abcdef" for ch in token):
25
+ try:
26
+ os.chmod(token_path, 0o600)
27
+ except OSError:
28
+ pass
29
+ return token
30
+ token = secrets.token_hex(32)
31
+ tmp = token_path.with_name(token_path.name + f".tmp.{os.getpid()}")
32
+ with open(tmp, "w", encoding="utf-8") as fh:
33
+ fh.write(token + "\n")
34
+ fh.flush()
35
+ os.fsync(fh.fileno())
36
+ os.chmod(tmp, 0o600)
37
+ os.replace(tmp, token_path)
38
+ return token
39
+ except OSError:
40
+ return secrets.token_hex(32)
41
+
42
+
43
+ def _read_exact(conn: socket.socket, count: int) -> bytes:
44
+ chunks: list[bytes] = []
45
+ remaining = count
46
+ while remaining > 0:
47
+ chunk = conn.recv(remaining)
48
+ if not chunk:
49
+ raise EOFError("socket closed while reading frame")
50
+ chunks.append(chunk)
51
+ remaining -= len(chunk)
52
+ return b"".join(chunks)
53
+
54
+
55
+ def _read_frame(conn: socket.socket) -> dict[str, Any]:
56
+ header = _read_exact(conn, 4)
57
+ length = struct.unpack(">I", header)[0]
58
+ if length <= 0 or length > _RPC_MAX_FRAME_BYTES:
59
+ raise ValueError("invalid broker RPC frame length")
60
+ payload = _read_exact(conn, length)
61
+ value = json.loads(payload.decode("utf-8"))
62
+ if not isinstance(value, dict):
63
+ raise ValueError("broker RPC response must be an object")
64
+ return value
65
+
66
+
67
+ def _write_frame(conn: socket.socket, payload: dict[str, Any]) -> None:
68
+ data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
69
+ conn.sendall(struct.pack(">I", len(data)) + data)
70
+
71
+
72
+ class PTYBrokerClient:
73
+ def __init__(self, socket_path: Path, token: str, *, timeout: float = 5.0) -> None:
74
+ self.socket_path = socket_path
75
+ self.token = token
76
+ self.timeout = timeout
77
+
78
+ def _rpc(self, op: str, **fields) -> dict:
79
+ request = {"op": op, "token": self.token, **fields}
80
+ deadline = time.time() + self.timeout
81
+ last_error: Exception | None = None
82
+ while True:
83
+ try:
84
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as conn:
85
+ conn.settimeout(max(0.25, min(1.0, deadline - time.time())))
86
+ conn.connect(str(self.socket_path))
87
+ _write_frame(conn, request)
88
+ response = _read_frame(conn)
89
+ if not response.get("ok"):
90
+ error = response.get("error") if isinstance(response.get("error"), dict) else {}
91
+ raise RuntimeError(str(error.get("message") or error.get("code") or "broker RPC failed"))
92
+ return response
93
+ except (FileNotFoundError, ConnectionRefusedError, socket.timeout, OSError) as exc:
94
+ last_error = exc
95
+ if time.time() >= deadline:
96
+ raise RuntimeError(f"PTY broker unavailable: {type(exc).__name__}: {exc}") from exc
97
+ time.sleep(0.05)
98
+ except Exception:
99
+ raise
100
+
101
+ def spawn(self, *, session_id: str, provider: str, native_id: str, project: str, command: str,
102
+ rows: int = 30, columns: int = 120, env: dict[str, str] | None = None) -> dict:
103
+ return self._rpc(
104
+ "spawn",
105
+ session_id=session_id,
106
+ provider=provider,
107
+ native_id=native_id,
108
+ project=project,
109
+ command=command,
110
+ rows=rows,
111
+ columns=columns,
112
+ env=env or {},
113
+ )["session"]
114
+
115
+ def get(self, session_id: str) -> dict | None:
116
+ return self._rpc("get", session_id=session_id).get("session")
117
+
118
+ def get_by_tty(self, tty: str) -> dict | None:
119
+ return self._rpc("get_by_tty", tty=tty).get("session")
120
+
121
+ def register_alias(self, alias: str, session: str | dict) -> None:
122
+ session_id = session.get("session_id") if isinstance(session, dict) else str(session or "")
123
+ if session_id:
124
+ self._rpc("register_alias", alias=alias, session_id=session_id)
125
+
126
+ def snapshot(self, session_id: str, public_session_id: str | None = None) -> dict | None:
127
+ return self._rpc("snapshot", session_id=session_id, public_session_id=public_session_id or "").get("snapshot")
128
+
129
+ def snapshot_v2(self, session_id: str, public_session_id: str | None = None) -> dict | None:
130
+ return self._rpc("snapshot_v2", session_id=session_id, public_session_id=public_session_id or "").get("surface")
131
+
132
+ def raw_tail(self, session_id: str, since: int = 0) -> tuple[bytes, int, int, bool] | None:
133
+ tail = self._rpc("raw_tail", session_id=session_id, since=max(0, int(since or 0))).get("tail")
134
+ if not isinstance(tail, dict):
135
+ return None
136
+ data = base64.b64decode(str(tail.get("b64") or ""))
137
+ return data, int(tail.get("next_offset") or 0), int(tail.get("total") or 0), bool(tail.get("reset"))
138
+
139
+ def control(self, session_id: str, action: dict) -> dict:
140
+ return self._rpc("control", session_id=session_id, action=action).get("result") or {"ok": False, "reason": "empty broker result"}
141
+
142
+ def send_text(self, session_id: str, text: str) -> dict:
143
+ return self._rpc("send_text", session_id=session_id, text=text).get("result") or {"ok": False, "reason": "empty broker result"}
144
+
145
+ def terminate(self, session_id: str, sig: int = signal.SIGTERM) -> dict:
146
+ return self._rpc("terminate", session_id=session_id, sig=int(sig)).get("result") or {"ok": False, "reason": "empty broker result"}
147
+
148
+ def status(self) -> dict:
149
+ status = self._rpc("status").get("status")
150
+ return status if isinstance(status, dict) else {}
151
+
152
+ def list_sessions(self) -> list[dict]:
153
+ sessions = self._rpc("list_sessions").get("sessions")
154
+ return sessions if isinstance(sessions, list) else []
155
+
156
+ def live_sessions(self) -> list[dict]:
157
+ return [
158
+ {
159
+ "broker_id": item.get("session_id"),
160
+ "provider": item.get("provider"),
161
+ "native_id": item.get("native_id"),
162
+ "slave_tty": item.get("slave_tty"),
163
+ "pid": item.get("pid"),
164
+ }
165
+ for item in self.list_sessions()
166
+ if isinstance(item, dict)
167
+ ]