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
|
@@ -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__(
|
|
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.
|
|
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
|
+
]
|