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/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
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))
|