witbitz-code 1.2.0__py3-none-any.whl

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.
witbitz_code/relay.py ADDED
@@ -0,0 +1,783 @@
1
+ """The sealed wire between the Spaces Code section and the connector on this computer (docs/opencode-relay.md §3–§4).
2
+
3
+ A second implementation of spaces/public/codeRelay.js, held to it by the pinned vectors in spaces/test/codeRelay.test.mjs
4
+ and by this package's cross-implementation tests (a frame sealed by either end opens in the other).
5
+
6
+ secret S (32 bytes, per computer × account pairing)
7
+ ├─ channel = b64url(HKDF(S, "channel")) → wss://code-relay.witbitz.chat/c/<channel> (the relay's routing key)
8
+ ├─ kC2S = HKDF(S, "client-to-computer") → AES-256-GCM, page → computer
9
+ └─ kS2C = HKDF(S, "computer-to-client") → AES-256-GCM, computer → page
10
+
11
+ A frame on the socket: {"v":1,"s":<sender id>,"q":<seq>,"n":<iv>,"c":<ciphertext>} — AAD "wbcr1|v|s|q", so the clear
12
+ header is authenticated; receivers drop any q ≤ the last one seen from that sender (replay).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import math
19
+ import os
20
+ import re
21
+ import time
22
+ from collections import OrderedDict
23
+ from collections.abc import Awaitable, Callable
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ from cryptography.hazmat.primitives import hashes
28
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
29
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
30
+
31
+ from . import _js
32
+ from ._js import UNDEFINED, b64u
33
+ from .auto import _sensitive_path # codeOutputs.js sensitivePath's twin: a secret-looking changed file keeps its patch here
34
+
35
+ RELAY_URL = "wss://code-relay.witbitz.chat"
36
+
37
+ # The relay's heartbeat (spaces/public/codeRelay.js RELAY_PING): the relay answers the ping itself, to the sender alone. A
38
+ # dead network path leaves a socket "open" for a quarter of an hour; no pong within PONG_WAIT_S → redial. Enforced only once
39
+ # this peer's relay has answered: an older relay broadcasts the first ping, and is sent no more.
40
+ RELAY_PING = '{"t":"relay-ping"}'
41
+ RELAY_PONG = '{"t":"relay-pong"}'
42
+ HEARTBEAT_S = 25.0
43
+ PONG_WAIT_S = 10.0
44
+ SALT = b"witbitz-code-relay-v1"
45
+ CHUNK = 192 * 1024 # UTF-16 code units of `b`, as in JS: Cloudflare caps a message at 1 MiB and a frame is ~4/3 of it
46
+ MAX_TOTAL = 32 * 1024 * 1024
47
+ MAX_FRAME = 8 * 1024 * 1024 # what this end will accept from the socket; the real relay caps at 1 MiB anyway
48
+ SEND_TIMEOUT_S = 30.0 # one frame (≤ ~256 KB) that cannot drain in this long means the path is dead, not slow
49
+ CLOSE_TIMEOUT_S = 3.0
50
+
51
+
52
+ def unb64u(s: Any) -> bytes:
53
+ """codeRelay.js unb64u: map the url alphabet back, pad, atob. Raises ValueError where atob throws."""
54
+ t = _js.js_string(s).replace("-", "+").replace("_", "/")
55
+ return _js.atob(t + "=" * ((4 - len(t) % 4) % 4))
56
+
57
+
58
+ def new_relay_secret() -> str:
59
+ """A new pairing secret: 32 random bytes, base64url."""
60
+ return b64u(os.urandom(32))
61
+
62
+
63
+ # ── keys ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
64
+ def _hkdf(raw: bytes, info: str) -> bytes:
65
+ return HKDF(algorithm=hashes.SHA256(), length=32, salt=SALT, info=info.encode()).derive(raw)
66
+
67
+
68
+ def derive_relay_bytes(secret: str) -> tuple[str, bytes, bytes]:
69
+ """Secret → (channel, raw c2s key, raw s2c key). Raw keys exist for the vector tests; use derive_relay otherwise."""
70
+ raw = unb64u(secret)
71
+ if len(raw) != 32:
72
+ raise ValueError("relay secret must be 32 bytes") # a truncated copy must fail loudly
73
+ return b64u(_hkdf(raw, "channel")), _hkdf(raw, "client-to-computer"), _hkdf(raw, "computer-to-client")
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class RelayKeys:
78
+ channel: str
79
+ c2s: AESGCM = field(repr=False)
80
+ s2c: AESGCM = field(repr=False)
81
+
82
+
83
+ def derive_relay(secret: str) -> RelayKeys:
84
+ channel, c2s, s2c = derive_relay_bytes(secret)
85
+ return RelayKeys(channel, AESGCM(c2s), AESGCM(s2c))
86
+
87
+
88
+ # ── frames ────────────────────────────────────────────────────────────────────────────────────────────────────────────
89
+ def _aad(v: Any, s: str, q: int) -> bytes:
90
+ return f"wbcr1|{_js.js_string(v)}|{s}|{q}".encode()
91
+
92
+
93
+ def _as_key(key: AESGCM | bytes) -> AESGCM:
94
+ return AESGCM(key) if isinstance(key, (bytes, bytearray)) else key
95
+
96
+
97
+ class Sealer:
98
+ """A sealer for ONE socket: its own random sender id and a counter."""
99
+
100
+ def __init__(self, key: AESGCM | bytes) -> None:
101
+ self._key = _as_key(key)
102
+ self.sender = b64u(os.urandom(12))
103
+ self._q = 0
104
+
105
+ def seal(self, msg: Any) -> str:
106
+ # Sealing is synchronous here, so the sequence number and the AAD can never disagree (the JS ★ bug); ordering on
107
+ # the socket is RelayPeer.send's job.
108
+ self._q += 1
109
+ seq = self._q
110
+ iv = os.urandom(12)
111
+ ct = self._key.encrypt(iv, _js.stringify(msg).encode("utf-8"), _aad(1, self.sender, seq))
112
+ return _js.stringify({"v": 1, "s": self.sender, "q": seq, "n": b64u(iv), "c": b64u(ct)})
113
+
114
+
115
+ class Opener:
116
+ """The other direction's key + a replay window per sender. open() returns the message, or None for anything that is
117
+ not a valid, fresh frame for this key (junk, the relay's own control frames, the wrong key, a replay)."""
118
+
119
+ def __init__(self, key: AESGCM | bytes, max_senders: int = 64, on_evict: Callable[[str], Any] | None = None) -> None:
120
+ self._key = _as_key(key)
121
+ self._max = max_senders
122
+ self._on_evict = on_evict
123
+ self._last: OrderedDict[str, int] = OrderedDict() # sender → highest q accepted, least recently used first
124
+
125
+ def open(self, text: Any) -> Any:
126
+ if not isinstance(text, str):
127
+ return None
128
+ try:
129
+ f = _js.parse(text)
130
+ except ValueError:
131
+ return None
132
+ if not isinstance(f, dict):
133
+ return None
134
+ v, s, q, n, c = (f.get(k, UNDEFINED) for k in ("v", "s", "q", "n", "c"))
135
+ if not (_js.is_num(v) and v == 1) or not isinstance(s, str) or not _js.is_safe_integer(q) or q < 1:
136
+ return None
137
+ if not isinstance(n, str) or not isinstance(c, str):
138
+ return None
139
+ q = int(q)
140
+ if q <= self._last.get(s, 0):
141
+ return None
142
+ try:
143
+ pt = self._key.decrypt(unb64u(n), unb64u(c), _aad(v, s, q))
144
+ except Exception: # bad base64, wrong key, tampered header or body — all the same to a receiver
145
+ return None
146
+ self._last.pop(s, None)
147
+ self._last[s] = q
148
+ if len(self._last) > self._max:
149
+ # A sender pushed out of the window could have its recorded frames replayed from now on — so say so: the
150
+ # connector rotates its nonce, which voids every request that sender ever sealed.
151
+ gone, _ = self._last.popitem(last=False)
152
+ _call(self._on_evict, gone)
153
+ try:
154
+ return _js.parse(pt.decode("utf-8-sig", "replace")) # TextDecoder: BOM dropped, bad bytes replaced
155
+ except ValueError:
156
+ return None
157
+
158
+
159
+ def peers_of(text: Any) -> int | None:
160
+ """Is this socket message the relay's own unsealed peer count? → n, else None."""
161
+ if not isinstance(text, str) or not text.startswith('{"t":"peers"'):
162
+ return None
163
+ try:
164
+ m = _js.parse(text)
165
+ except ValueError:
166
+ return None
167
+ n = m.get("n") if isinstance(m, dict) else None
168
+ return int(n) if _js.is_integer(n) else None
169
+
170
+
171
+ # ── chunking ──────────────────────────────────────────────────────────────────────────────────────────────────────────
172
+ def chunk_message(msg: Any, size: int = CHUNK) -> list:
173
+ """A message whose string field `b` is large → parts {…msg, b: slice, part: i, of: n}; small → [msg].
174
+ Sizes and slices are UTF-16 code units, so both implementations cut a body at the same places."""
175
+ b = msg.get("b") if isinstance(msg, dict) else None
176
+ if not isinstance(b, str):
177
+ return [msg]
178
+ units = b.encode("utf-16-le", "surrogatepass")
179
+ total = len(units) // 2
180
+ if total <= size:
181
+ return [msg]
182
+ of = math.ceil(total / size)
183
+ return [
184
+ {**msg, "b": units[2 * i * size : 2 * (i + 1) * size].decode("utf-16-le", "surrogatepass"), "part": i, "of": of}
185
+ for i in range(of)
186
+ ]
187
+
188
+
189
+ def _now_ms() -> float:
190
+ return time.time() * 1000
191
+
192
+
193
+ @dataclass
194
+ class _Partial:
195
+ of: int
196
+ at: float
197
+ parts: dict = field(default_factory=dict)
198
+ size: int = 0
199
+
200
+
201
+ class Reassembler:
202
+ """Collects parts by (t, id). push(msg) → the whole message once complete, the message itself if unchunked, else None."""
203
+
204
+ def __init__(self, timeout_ms: float = 60_000, max_total: int = MAX_TOTAL, clock: Callable[[], float] = _now_ms):
205
+ self._open: dict[str, _Partial] = {}
206
+ self._timeout = timeout_ms
207
+ self._max = max_total
208
+ self._clock = clock
209
+
210
+ def push(self, msg: Any) -> Any:
211
+ if not isinstance(msg, dict) or "of" not in msg:
212
+ return msg
213
+ of, part, b = msg["of"], msg.get("part", UNDEFINED), msg.get("b")
214
+ if not _js.is_integer(of) or of < 1 or not _js.is_integer(part) or part < 0 or part >= of or not isinstance(b, str):
215
+ return None
216
+ if of > 2**32 - 1: # JS `new Array(of)` throws past this; the message is dropped there too
217
+ return None
218
+ of, part = int(of), int(part)
219
+ k = f"{_js.js_string(msg.get('t', UNDEFINED))}:{_js.js_string(msg.get('id', UNDEFINED))}"
220
+ now = self._clock()
221
+ for key in [key for key, e in self._open.items() if now - e.at > self._timeout]:
222
+ del self._open[key]
223
+ e = self._open.get(k)
224
+ if e is None:
225
+ e = self._open[k] = _Partial(of=of, at=now)
226
+ if e.of != of:
227
+ del self._open[k]
228
+ return None
229
+ if part not in e.parts:
230
+ e.parts[part] = b
231
+ e.size += _js.utf16_len(b)
232
+ if e.size > self._max:
233
+ del self._open[k]
234
+ return None
235
+ if len(e.parts) < of:
236
+ return None
237
+ del self._open[k]
238
+ whole = dict(msg)
239
+ whole["b"] = _js.utf16_join(e.parts[i] for i in range(of))
240
+ whole.pop("part", None)
241
+ whole.pop("of", None)
242
+ return whole
243
+
244
+
245
+ # ── the socket ────────────────────────────────────────────────────────────────────────────────────────────────────────
246
+ Connect = Callable[[str], Awaitable[Any]]
247
+
248
+
249
+ async def default_connect(url: str) -> Any:
250
+ from websockets.asyncio.client import connect
251
+
252
+ # No Origin header (the relay admits a socket without one — only browsers must carry an allowed Origin), no
253
+ # compression (the payload is ciphertext). Protocol pings notice a dead TCP path that a sealed hello cannot.
254
+ return await connect(url, max_size=MAX_FRAME, compression=None, open_timeout=15, close_timeout=2,
255
+ ping_interval=30, ping_timeout=30)
256
+
257
+
258
+ def _call(fn: Callable | None, *args: Any) -> None:
259
+ if fn is None:
260
+ return
261
+ try:
262
+ fn(*args)
263
+ except Exception: # a handler bug must not kill the socket
264
+ pass
265
+
266
+
267
+ class RelayPeer:
268
+ """One end of a channel, with reconnect. role 'client' (the page) seals with c2s and opens s2c; 'computer' the reverse.
269
+
270
+ peer = RelayPeer(secret=..., role="computer", on_message=..., on_peers=..., on_state=...)
271
+ await peer.start(); await peer.send({"t": "hello"}); await peer.aclose()
272
+
273
+ A fresh sealer (new sender id, q from 1) per socket, so a reconnect is never mistaken for a replay.
274
+ """
275
+
276
+ def __init__(self, *, secret: str, role: str, relay: str = RELAY_URL, on_message: Callable | None = None,
277
+ on_peers: Callable | None = None, on_state: Callable | None = None, on_evict: Callable | None = None,
278
+ max_senders: int = 64, min_backoff: float = 0.5, max_backoff: float = 15.0, connect: Connect | None = None,
279
+ heartbeat: float = HEARTBEAT_S, pong_wait: float = PONG_WAIT_S) -> None:
280
+ if role not in ("client", "computer"):
281
+ raise ValueError("role must be client or computer")
282
+ self.secret = secret
283
+ self.role = role
284
+ self.relay = re.sub(r"/+$", "", _js.js_string(relay))
285
+ self.on_message, self.on_peers, self.on_state, self.on_evict = on_message, on_peers, on_state, on_evict
286
+ self.max_senders = max_senders
287
+ self.min_backoff, self.max_backoff = min_backoff, max_backoff
288
+ self.peers = 0
289
+ self.state = "idle" # idle | connecting | open | closed
290
+ self._connect = connect or default_connect
291
+ self._keys: RelayKeys | None = None
292
+ self._ws: Any = None
293
+ self._sealer: Sealer | None = None
294
+ self._backoff = min_backoff
295
+ self._stopped = True
296
+ self._task: asyncio.Task | None = None
297
+ self._stopping_task: asyncio.Task | None = None
298
+ self._wake: asyncio.Event | None = None
299
+ self.waiting = False # inside a backoff wait (state reads 'connecting' then too)
300
+ self._redial = False
301
+ self._send_chain: asyncio.Future | None = None
302
+ self._closing: set[asyncio.Task] = set()
303
+ self.heartbeat, self.pong_wait = heartbeat, pong_wait
304
+ self.relay_answers = False # this socket's relay has answered a ping
305
+ self.relay_known = False # ...or an earlier socket's did: a missing pong then means a dead path from the first ping
306
+ self._pinged_unanswered = False
307
+ self._last_pong = 0.0
308
+ self._probes: set[asyncio.Task] = set()
309
+
310
+ @property
311
+ def channel(self) -> str | None:
312
+ return self._keys.channel if self._keys else None
313
+
314
+ @property
315
+ def is_open(self) -> bool:
316
+ return self.state == "open"
317
+
318
+ async def start(self) -> None:
319
+ self._stopped = False
320
+ if self._keys is None:
321
+ self._keys = derive_relay(self.secret)
322
+ old = self._task
323
+ if old is not None and not old.done():
324
+ if old is not self._stopping_task:
325
+ return # already running
326
+ await asyncio.wait([old]) # start() right after stop(): let the cancelled loop finish before dialling again
327
+ self._stopping_task = None
328
+ self._task = asyncio.get_running_loop().create_task(self._run())
329
+
330
+ def stop(self) -> None:
331
+ self._stopped = True
332
+ self.waiting = False # a stop during a backoff wait must not leave kick() believing a wait is pending
333
+ if self._task is not None:
334
+ self._task.cancel()
335
+ self._stopping_task = self._task
336
+ self._close_ws(self._ws)
337
+ self._ws = None
338
+ self._set_state("closed")
339
+
340
+ async def aclose(self) -> None:
341
+ """stop(), then wait (bounded) for the socket task and the close handshakes to finish."""
342
+ self.stop()
343
+ pending = [t for t in [self._task, *self._closing] if t is not None]
344
+ if pending:
345
+ await asyncio.wait(pending, timeout=CLOSE_TIMEOUT_S + 1)
346
+
347
+ def kick(self) -> None:
348
+ """Reconnect now (e.g. the page became visible again) instead of waiting out the backoff. An "open" socket is asked
349
+ about instead: the relay must answer a ping, or it is redialled (a socket frozen, not closed).
350
+ ★ `connecting` covers two situations: a socket actually dialling (leave it) and a backoff WAIT (skip it)."""
351
+ if not self._stopped and self.state == "open":
352
+ self.probe()
353
+ return
354
+ if self._stopped or (self.state == "connecting" and not self.waiting):
355
+ return
356
+ self._backoff = self.min_backoff
357
+ if self._wake is not None:
358
+ self._wake.set()
359
+
360
+ def probe(self) -> None:
361
+ """Ping the relay on the current socket; no pong within pong_wait → reconnect()."""
362
+ ws = self._ws
363
+ if self._stopped or ws is None or self.state != "open":
364
+ return
365
+ try:
366
+ t = asyncio.get_running_loop().create_task(self._probe(ws))
367
+ except RuntimeError:
368
+ return
369
+ self._probes.add(t)
370
+ t.add_done_callback(self._probes.discard)
371
+
372
+ async def _probe(self, ws: Any) -> None:
373
+ enforce = self.relay_answers or self.relay_known
374
+ if not enforce and self._pinged_unanswered:
375
+ return # an older relay: it broadcasts pings — send it no more
376
+ sent = time.monotonic()
377
+ try:
378
+ await ws.send(RELAY_PING)
379
+ except Exception:
380
+ return
381
+ if not enforce:
382
+ self._pinged_unanswered = True # learning whether this relay answers at all
383
+ return
384
+ await asyncio.sleep(self.pong_wait)
385
+ if self._ws is ws and not self._stopped and self.state == "open" and not self._last_pong >= sent:
386
+ self.reconnect() # a dead path: the relay did not hear us, or we did not hear it
387
+
388
+ async def _heartbeat(self, ws: Any) -> None:
389
+ while self._ws is ws and not self._stopped:
390
+ self.probe()
391
+ await asyncio.sleep(self.heartbeat)
392
+
393
+ def reconnect(self) -> None:
394
+ """Drop the current socket and dial again now — for a socket that is "open" but has gone silent (a dead path)."""
395
+ if self._stopped:
396
+ return
397
+ ws, self._ws = self._ws, None
398
+ self.peers = 0
399
+ self._backoff = self.min_backoff
400
+ if ws is not None:
401
+ self._redial = True # the socket loop ends without a peers callback or a backoff, as the JS onclose guard does
402
+ self._close_ws(ws, 4000)
403
+ elif self._wake is not None:
404
+ self._wake.set()
405
+
406
+ def _set_state(self, s: str) -> None:
407
+ if self.state != s:
408
+ self.state = s
409
+ _call(self.on_state, s)
410
+
411
+ def _close_ws(self, ws: Any, code: int = 1000) -> None:
412
+ if ws is None:
413
+ return
414
+ try:
415
+ t = asyncio.get_running_loop().create_task(_close_quietly(ws, code))
416
+ except Exception:
417
+ _abort(ws)
418
+ return
419
+ self._closing.add(t)
420
+ t.add_done_callback(self._closing.discard)
421
+
422
+ async def _run(self) -> None:
423
+ keys = self._keys
424
+ assert keys is not None
425
+ url = f"{self.relay}/c/{keys.channel}"
426
+ while not self._stopped:
427
+ self._set_state("connecting")
428
+ try:
429
+ ws = await self._connect(url)
430
+ except asyncio.CancelledError:
431
+ raise
432
+ except Exception:
433
+ ws = None
434
+ if ws is not None:
435
+ await self._serve_socket(ws, keys)
436
+ else:
437
+ self.peers = 0
438
+ _call(self.on_peers, 0)
439
+ if self._stopped:
440
+ return
441
+ if self._redial:
442
+ self._redial = False
443
+ continue # reconnect(): dial again at once
444
+ self._set_state("connecting")
445
+ wait, self._backoff = self._backoff, min(self.max_backoff, self._backoff * 2)
446
+ self._wake = asyncio.Event()
447
+ self.waiting = True
448
+ try:
449
+ await asyncio.wait_for(self._wake.wait(), wait)
450
+ except asyncio.TimeoutError:
451
+ pass
452
+ finally:
453
+ self._wake = None
454
+ self.waiting = False
455
+
456
+ async def _serve_socket(self, ws: Any, keys: RelayKeys) -> None:
457
+ sealer = Sealer(keys.c2s if self.role == "client" else keys.s2c)
458
+ opener = Opener(keys.s2c if self.role == "client" else keys.c2s, self.max_senders, lambda s: _call(self.on_evict, s))
459
+ reasm = Reassembler()
460
+ self._ws, self._sealer = ws, sealer
461
+ self._backoff = self.min_backoff
462
+ self.relay_answers, self._pinged_unanswered, self._last_pong = False, False, 0.0
463
+ self._set_state("open")
464
+ beat = asyncio.get_running_loop().create_task(self._heartbeat(ws)) if self.heartbeat > 0 else None
465
+ try:
466
+ # One loop, one frame at a time: frames are opened in the order they arrived (the JS recvChain).
467
+ async for data in ws:
468
+ if self._ws is not ws:
469
+ break
470
+ if not isinstance(data, str):
471
+ continue
472
+ if data == RELAY_PONG:
473
+ self.relay_answers = self.relay_known = True
474
+ self._pinged_unanswered = False
475
+ self._last_pong = time.monotonic()
476
+ continue
477
+ if data == RELAY_PING:
478
+ continue # another peer's ping, broadcast by a relay that does not answer it
479
+ n = peers_of(data)
480
+ if n is not None:
481
+ self.peers = n
482
+ _call(self.on_peers, n)
483
+ continue
484
+ msg = reasm.push(opener.open(data))
485
+ if _js.truthy(msg):
486
+ _call(self.on_message, msg)
487
+ except asyncio.CancelledError:
488
+ self._close_ws(ws)
489
+ raise
490
+ except Exception:
491
+ pass # the socket went away; reconnect
492
+ finally:
493
+ if beat is not None:
494
+ beat.cancel()
495
+ replaced = self._ws is not ws
496
+ if not replaced:
497
+ self._ws = None
498
+ if replaced:
499
+ return # reconnect() (or stop()) took this socket away: its close is not news
500
+ self._close_ws(ws)
501
+ self.peers = 0
502
+ _call(self.on_peers, 0)
503
+
504
+ def send(self, msg: Any) -> asyncio.Future:
505
+ """Seal and send (chunked if large). The returned future resolves False when there is no open socket.
506
+
507
+ ★ ONE AT A TIME, IN CALL ORDER. Receivers drop a frame whose sequence is not above the last one opened, so frames
508
+ must reach the socket in the order they were numbered. Each send waits for the previous one — chained at call
509
+ time, so fire-and-forget sends from a timer or handler still go out in the order they were made.
510
+ """
511
+ loop = asyncio.get_running_loop()
512
+ prev = self._send_chain
513
+
514
+ async def run() -> bool:
515
+ if prev is not None and not prev.done():
516
+ await asyncio.wait([prev])
517
+ return await self._send_now(msg)
518
+
519
+ task = loop.create_task(run())
520
+ task.add_done_callback(_consume)
521
+ self._send_chain = task
522
+ return task
523
+
524
+ async def _send_now(self, msg: Any) -> bool:
525
+ ws = self._ws
526
+ if ws is None or self.state != "open" or self._sealer is None:
527
+ return False
528
+ sealer = self._sealer
529
+ for part in chunk_message(msg):
530
+ frame = sealer.seal(part)
531
+ if self._ws is not ws:
532
+ return False
533
+ try:
534
+ await asyncio.wait_for(ws.send(frame), SEND_TIMEOUT_S)
535
+ except asyncio.TimeoutError:
536
+ # ★ A send stuck on a dead TCP path (the laptop slept, Wi-Fi dropped) never returns, and websockets' own
537
+ # keepalive ping waits behind the same full buffer — so nothing else would notice. Drop the socket: the
538
+ # receive loop ends, queued sends resolve False, and the peer reconnects.
539
+ _abort(ws)
540
+ return False
541
+ except Exception:
542
+ return False
543
+ return True
544
+
545
+
546
+ def _abort(ws: Any) -> None:
547
+ transport = getattr(ws, "transport", None)
548
+ if transport is not None:
549
+ try:
550
+ transport.abort()
551
+ except Exception:
552
+ pass
553
+
554
+
555
+ async def _close_quietly(ws: Any, code: int = 1000) -> None:
556
+ """A close handshake, bounded: on a dead path it would wait on the same undrainable buffer as a send."""
557
+ try:
558
+ await asyncio.wait_for(ws.close(code), CLOSE_TIMEOUT_S)
559
+ except asyncio.CancelledError:
560
+ _abort(ws)
561
+ raise
562
+ except Exception:
563
+ _abort(ws)
564
+
565
+
566
+ def _consume(t: asyncio.Future) -> None:
567
+ if not t.cancelled():
568
+ t.exception() # retrieved, so a fire-and-forget failure is not reported as "never retrieved"
569
+
570
+
571
+ # ── what the connector serves ─────────────────────────────────────────────────────────────────────────────────────────
572
+ # Only the calls the Code section makes (opencodeApp.js). OpenCode can run shell commands on the computer; a leaked
573
+ # secret must not unlock more than the page itself can do. Paths carry their query string (?directory=…).
574
+ # A segment may contain dots but never START with one: '.' and '..' are collapsed by HTTP clients (fetch and httpx alike)
575
+ # onto a route that is not on this list — `DELETE /session/.` would reach DELETE /session.
576
+ _SEG = r"(?!\.)[A-Za-z0-9_.-]{1,128}"
577
+ _ALLOW = [
578
+ (m, re.compile(p))
579
+ for m, p in [
580
+ ("GET", "/experimental/session"),
581
+ ("GET", "/agent"),
582
+ ("GET", "/api/model"),
583
+ ("GET", "/config"), # answered through project_response — never as OpenCode sent it
584
+ ("GET", "/config/providers"), # the model menu: what is CONNECTED on this computer (project_response, no keys)
585
+ ("POST", "/session"),
586
+ ("GET", f"/session/{_SEG}/message"),
587
+ ("POST", f"/session/{_SEG}/message"),
588
+ ("POST", f"/session/{_SEG}/abort"),
589
+ ("POST", f"/session/{_SEG}/permissions/{_SEG}"),
590
+ ("POST", f"/permission/{_SEG}/reply"), # the same answer with a message for the model (a person's Deny)
591
+ ("GET", "/permission"), # what is still waiting — a card comes back after switching sessions (the asks the stream carries)
592
+ ("PATCH", f"/session/{_SEG}"),
593
+ ("DELETE", f"/session/{_SEG}"),
594
+ # New session's folder picker: the computer's home, and folder listings under it (names, never contents)
595
+ ("GET", "/path"),
596
+ ("GET", "/file"),
597
+ # the agent's question tool: what is pending, and the answer or the dismissal (inside the agent loop)
598
+ # the "/" menu reads commands and skills; POST /session/:id/command runs !`…` from its arguments — never listed
599
+ ("GET", "/command"),
600
+ ("GET", f"/session/{_SEG}/todo"), # the agent's todo list, as it stands
601
+ ("GET", "/question"),
602
+ ("POST", f"/question/{_SEG}/reply"),
603
+ ("POST", f"/question/{_SEG}/reject"),
604
+ # /undo /redo /compact: files back to OpenCode's pre-turn snapshot, forward again, and a model-written summary
605
+ ("POST", f"/session/{_SEG}/revert"),
606
+ ("POST", f"/session/{_SEG}/unrevert"),
607
+ ("POST", f"/session/{_SEG}/summarize"),
608
+ ("GET", "/session/status"), # which sessions are running a turn, for the list (reads only)
609
+ # the Changes view: the folder's changed files and their git diff (reads only; /vcs/diff is projected; never /vcs/apply)
610
+ ("GET", "/vcs/status"),
611
+ ("GET", "/vcs/diff"),
612
+ ]
613
+ ]
614
+
615
+
616
+ def allowed_request(method: Any, path_with_query: Any) -> bool:
617
+ """Is `method path?query` something the connector will forward? The event stream is NOT here: it is `sub`."""
618
+ m = _js.js_string(method if _js.truthy(method) else "").upper()
619
+ p = _js.js_string(path_with_query if _js.truthy(path_with_query) else "")
620
+ if not p.startswith("/") or ".." in p or "//" in p or "#" in p:
621
+ return False
622
+ path = p.split("?")[0]
623
+ return any(am == m and rx.fullmatch(path) for am, rx in _ALLOW) # fullmatch: JS `$` never matches before a \n
624
+
625
+
626
+ _EVENT = re.compile(r"/event\?directory=[^&#]*")
627
+
628
+
629
+ def allowed_event_path(p: Any) -> bool:
630
+ """The event-stream path a `sub` may name: /event, optionally ?directory=… (and nothing else)."""
631
+ s = _js.js_string(p if _js.truthy(p) else "")
632
+ return s == "/event" or bool(_EVENT.fullmatch(s))
633
+
634
+
635
+ # ── what the connector gives back ─────────────────────────────────────────────────────────────────────────────────────
636
+ # Two routes the page needs answer with SECRETS in them (measured, opencode 1.18): GET /config resolves every `{env:…}` in
637
+ # the provider options, and GET /config/providers carries each connected provider's stored API key in `key`. The page
638
+ # needs names, not credentials, so the connector rebuilds those answers from an ALLOWLIST of fields — codeRelay.js
639
+ # `projectResponse`, which this mirrors field for field.
640
+ _PROJECTED = {"/config", "/config/providers", "/vcs/diff"}
641
+ # GET /vcs/diff: a secret-looking file's patch (an untracked .env comes back whole) or one too large for a phone stays here
642
+ MAX_PATCH_CHARS = 1_000_000
643
+ _MAX_DIFF_FILES = 5000
644
+
645
+
646
+ def _count(v: Any) -> int:
647
+ return math.floor(v) if _js.is_num(v) and math.isfinite(v) and v >= 0 else 0
648
+
649
+
650
+ def _project_vcs_diff(items: list) -> list:
651
+ out = []
652
+ for x in items[:_MAX_DIFF_FILES]:
653
+ if not isinstance(x, dict) or not isinstance(x.get("file"), str) or not x["file"]:
654
+ continue
655
+ o = {"file": _js.utf16_slice(x["file"], 0, 4096), "status": x.get("status") if x.get("status") in ("added", "deleted", "modified") else "modified",
656
+ "additions": _count(x.get("additions")), "deletions": _count(x.get("deletions"))}
657
+ patch = x.get("patch") if isinstance(x.get("patch"), str) else ""
658
+ if _sensitive_path("/" + o["file"]):
659
+ o["withheld"] = "secret"
660
+ elif _js.utf16_len(patch) > MAX_PATCH_CHARS:
661
+ o["withheld"] = "size"
662
+ else:
663
+ o["patch"] = patch
664
+ out.append(o)
665
+ return out
666
+ _MAX_PROVIDERS, _MAX_MODELS = 100, 2000
667
+ _MEDIA = ["text", "image", "pdf", "audio", "video"]
668
+
669
+
670
+ def _s200(v: Any) -> str | None:
671
+ return _js.utf16_slice(v, 0, 200) if isinstance(v, str) else None
672
+
673
+
674
+ def _project_input(caps: Any) -> list | None:
675
+ inp = caps.get("input") if isinstance(caps, dict) else None
676
+ if isinstance(inp, list):
677
+ return [k for k in _MEDIA if k in inp]
678
+ if isinstance(inp, dict):
679
+ return [k for k in _MEDIA if inp.get(k) is True]
680
+ return None
681
+
682
+
683
+ def _project_models(models: Any, keep: Callable[[dict], dict]) -> dict:
684
+ out: dict = {}
685
+ if not isinstance(models, dict):
686
+ return out
687
+ for k in list(models.keys())[:_MAX_MODELS]:
688
+ mid = _s200(k)
689
+ if not mid:
690
+ continue
691
+ m = models[k] if isinstance(models[k], dict) else {}
692
+ out[mid] = keep(m)
693
+ return out
694
+
695
+
696
+ def _named(m: dict) -> dict:
697
+ return {"name": _s200(m.get("name"))} if _s200(m.get("name")) else {}
698
+
699
+
700
+ def _project_config(c: dict) -> dict:
701
+ out: dict = {}
702
+ if _s200(c.get("model")):
703
+ out["model"] = _s200(c.get("model"))
704
+ if _s200(c.get("small_model")):
705
+ out["small_model"] = _s200(c.get("small_model"))
706
+ src = c.get("provider") if isinstance(c.get("provider"), dict) else {}
707
+ provider: dict = {}
708
+ for pid in list(src.keys())[:_MAX_PROVIDERS]:
709
+ if not _s200(pid):
710
+ continue
711
+ p = src[pid] if isinstance(src[pid], dict) else {}
712
+ entry: dict = {"models": _project_models(p.get("models"), _named)}
713
+ if _s200(p.get("name")):
714
+ entry["name"] = _s200(p.get("name"))
715
+ provider[_s200(pid)] = entry
716
+ out["provider"] = provider
717
+ return out
718
+
719
+
720
+ # A model's reasoning VARIANTS (the Code section's Effort row): their names only, never the options behind them — twin of
721
+ # codeRelay.js projectVariants.
722
+ _VARIANT_NAME = re.compile(r"[a-z][a-z0-9_-]{0,15}")
723
+
724
+
725
+ def _project_variants(v: Any) -> list | None:
726
+ if not isinstance(v, dict):
727
+ return None
728
+ names = [k for k in v.keys() if isinstance(k, str) and _VARIANT_NAME.fullmatch(k)][:8]
729
+ return names or None
730
+
731
+
732
+ def _provider_model(m: dict) -> dict:
733
+ o: dict = {}
734
+ if _s200(m.get("name")):
735
+ o["name"] = _s200(m.get("name"))
736
+ if _s200(m.get("status")):
737
+ o["status"] = _s200(m.get("status"))
738
+ inp = _project_input(m.get("capabilities"))
739
+ if inp is not None:
740
+ o["input"] = inp
741
+ variants = _project_variants(m.get("variants"))
742
+ if variants:
743
+ o["variants"] = variants
744
+ return o
745
+
746
+
747
+ def _project_providers(d: dict) -> dict:
748
+ providers = []
749
+ for p in (d.get("providers") if isinstance(d.get("providers"), list) else [])[:_MAX_PROVIDERS]:
750
+ if not isinstance(p, dict) or not _s200(p.get("id")):
751
+ continue
752
+ entry: dict = {"id": _s200(p.get("id")), "models": _project_models(p.get("models"), _provider_model)}
753
+ if _s200(p.get("name")):
754
+ entry["name"] = _s200(p.get("name"))
755
+ if _s200(p.get("source")):
756
+ entry["source"] = _s200(p.get("source"))
757
+ providers.append(entry)
758
+ default: dict = {}
759
+ if isinstance(d.get("default"), dict):
760
+ for k in list(d["default"].keys())[:_MAX_PROVIDERS]:
761
+ if _s200(k) and _s200(d["default"][k]):
762
+ default[_s200(k)] = _s200(d["default"][k])
763
+ return {"providers": providers, "default": default}
764
+
765
+
766
+ def project_response(method: Any, path_with_query: Any, status: int, text: str) -> tuple[int, str]:
767
+ """The answer the connector sends for `method path` — projected for the routes above, untouched otherwise."""
768
+ path = _js.js_string(path_with_query if _js.truthy(path_with_query) else "").split("?")[0]
769
+ if _js.js_string(method if _js.truthy(method) else "").upper() != "GET" or path not in _PROJECTED:
770
+ return status, text
771
+ if not 200 <= status < 300:
772
+ return status, _js.stringify({"error": f"OpenCode answered {status} for {path}"})
773
+ try:
774
+ v = _js.parse(text)
775
+ except Exception:
776
+ v = None
777
+ if path == "/vcs/diff":
778
+ if isinstance(v, list):
779
+ return status, _js.stringify(_project_vcs_diff(v))
780
+ return 502, _js.stringify({"error": f"OpenCode sent an unexpected answer for {path}"})
781
+ if not isinstance(v, dict):
782
+ return 502, _js.stringify({"error": f"OpenCode sent an unexpected answer for {path}"})
783
+ return status, _js.stringify(_project_config(v) if path == "/config" else _project_providers(v))