muse-cli 0.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.
muse_cli/gateway.py ADDED
@@ -0,0 +1,336 @@
1
+ """Muse gateway client: HTTPS auth + WebSocket/Noise-XX transport + protobuf envelopes.
2
+
3
+ Derived from the muse.ai web app's own protocol (route table + framing observed
4
+ in its client bundle). Talks to the user's personal VM gateway directly.
5
+ No browser needed after the initial cookie export.
6
+ """
7
+ import json
8
+ import os
9
+ import struct
10
+ import threading
11
+ import time
12
+ import urllib.parse
13
+ import uuid
14
+ import queue as queue_mod
15
+
16
+ from curl_cffi import requests as rq
17
+ from curl_cffi.requests import WebSocket
18
+ from noise.connection import NoiseConnection, Keypair
19
+ from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
20
+
21
+ _HERE = os.path.dirname(os.path.abspath(__file__))
22
+ UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
23
+ "(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
24
+ GATEWAY_HOST = "hatch.metaaivm.com"
25
+
26
+ _pool = descriptor_pool.DescriptorPool()
27
+ for _i in (0, 1):
28
+ _fd = descriptor_pb2.FileDescriptorProto()
29
+ with open(os.path.join(_HERE, f"desc{_i}.bin"), "rb") as fh:
30
+ _fd.ParseFromString(fh.read())
31
+ _pool.Add(_fd)
32
+
33
+
34
+ def _msg(name):
35
+ return message_factory.GetMessageClass(_pool.FindMessageTypeByName(name))
36
+
37
+
38
+ NoiseTransportFrame = _msg("ingress_rev_proxy.NoiseTransportFrame")
39
+ ServiceRequest = _msg("hatch.noise.ServiceRequest")
40
+ ServiceResponse = _msg("hatch.noise.ServiceResponse")
41
+ ServiceFrame = _msg("hatch.noise.ServiceFrame")
42
+ ApplicationRequest = _msg("hatch.noise.ApplicationRequest")
43
+
44
+ with open(os.path.join(_HERE, "routes.json")) as fh:
45
+ ROUTES = {e["method"]: e for e in json.load(fh)}
46
+
47
+ SERVICE_IDS = {"daemon": 0, "sentinel": 1, "vault": 2, "authd": 3}
48
+
49
+
50
+ class AuthError(RuntimeError):
51
+ pass
52
+
53
+
54
+ class GatewayError(RuntimeError):
55
+ def __init__(self, status, payload):
56
+ super().__init__(f"gateway status={status} payload={payload!r}")
57
+ self.status = status
58
+ self.payload = payload
59
+
60
+
61
+ def _hatch_headers(cookies, access_token=None):
62
+ h = {
63
+ "User-Agent": UA,
64
+ "Referer": "https://muse.ai/",
65
+ "Origin": "https://muse.ai",
66
+ "Sec-Fetch-Site": "same-origin",
67
+ "Sec-Fetch-Mode": "cors",
68
+ "Sec-Fetch-Dest": "empty",
69
+ "Accept": "application/json",
70
+ "Content-Type": "application/json",
71
+ "Cookie": cookies,
72
+ }
73
+ if access_token:
74
+ h["Authorization"] = f"Bearer {access_token}"
75
+ return h
76
+
77
+
78
+ def fetch_access_token(cookies):
79
+ r = rq.post("https://muse.ai/api/auth/check", headers=_hatch_headers(cookies),
80
+ data=b"", impersonate="chrome", timeout=20)
81
+ if r.status_code != 200:
82
+ raise AuthError(f"auth/check -> {r.status_code} {r.text[:120]} (cookies expired? re-export)")
83
+ return r.json()["access_token"]
84
+
85
+
86
+ def fetch_hatch_token(cookies, access_token, vm_id):
87
+ r = rq.post(
88
+ "https://muse.ai/api/hatch/token",
89
+ headers=_hatch_headers(cookies, access_token),
90
+ json={"vmAddress": f"wss://{vm_id}.metaaivm.com/", "vmName": vm_id},
91
+ impersonate="chrome", timeout=20,
92
+ )
93
+ if r.status_code != 200:
94
+ raise AuthError(f"hatch/token -> {r.status_code} {r.text[:160]}")
95
+ return r.json()["token"]
96
+
97
+
98
+ def load_cookies(path):
99
+ """Accept a curl-style 'a=b; c=d' file or {"cookies": {...}} JSON."""
100
+ raw = open(path).read().strip()
101
+ if raw.startswith("{"):
102
+ try:
103
+ d = json.loads(raw)
104
+ if isinstance(d, dict) and "cookies" in d and isinstance(d["cookies"], dict):
105
+ return "; ".join(f"{k}={v}" for k, v in d["cookies"].items())
106
+ return "; ".join(f"{k}={v}" for k, v in d.items())
107
+ except json.JSONDecodeError:
108
+ pass
109
+ if "hatch_sess=" in raw or "datr=" in raw:
110
+ for line in raw.splitlines():
111
+ line = line.strip()
112
+ if line and not line.startswith("#") and "=" in line and ";" in line:
113
+ return line
114
+ return " ".join(raw.split())
115
+ # Netscape cookie-jar format
116
+ parts = []
117
+ for line in raw.splitlines():
118
+ if not line or line.startswith("#"):
119
+ continue
120
+ cols = line.split("\t")
121
+ if len(cols) >= 7:
122
+ parts.append(f"{cols[5]}={cols[6]}")
123
+ if not parts:
124
+ raise AuthError(f"could not parse cookies file {path}")
125
+ return "; ".join(parts)
126
+
127
+
128
+ def fetch_session_info(cookies):
129
+ """Discover the user's assigned personal VM (id + gateway URL)."""
130
+ r = rq.get("https://muse.ai/api/session", headers=_hatch_headers(cookies),
131
+ impersonate="chrome", timeout=20)
132
+ if r.status_code != 200:
133
+ raise AuthError(f"api/session -> {r.status_code} (cookies expired? re-export)")
134
+ try:
135
+ info = r.json()
136
+ except ValueError:
137
+ raise AuthError(f"api/session returned non-JSON ({r.text[:80]!r})")
138
+ if "vm_id" not in info:
139
+ # The VM is down/restarting: {"status":"unavailable",
140
+ # "vm_resolution_issue":{"kind":"retryable"}}. Not an auth problem,
141
+ # so say so: retry, or wake a known VM id directly.
142
+ raise GatewayError(-1, f"VM unavailable ({info!r}); retry shortly or "
143
+ "`MUSE_VM_ID=<id> muse-cli wake`")
144
+ return info
145
+
146
+
147
+ class Gateway:
148
+ """One authenticated connection to the personal VM gateway."""
149
+
150
+ def __init__(self, cookies, vm_id=None, access_token=None, hatch_token=None):
151
+ if vm_id is None:
152
+ vm_id = fetch_session_info(cookies)["vm_id"]
153
+ self.vm_id = vm_id
154
+ access_token = access_token or fetch_access_token(cookies)
155
+ hatch_token = hatch_token or fetch_hatch_token(cookies, access_token, vm_id)
156
+ url = (f"wss://{GATEWAY_HOST}/v1/noise?vm_id={vm_id}"
157
+ f"&auth_token={urllib.parse.quote(hatch_token, safe='')}")
158
+ self.ws = WebSocket()
159
+ self.ws.connect(url, impersonate="chrome", timeout=20)
160
+ noise = NoiseConnection.from_name(b"Noise_XX_25519_AESGCM_SHA256")
161
+ noise.set_as_initiator()
162
+ noise.set_keypair_from_private_bytes(Keypair.STATIC, os.urandom(32))
163
+ noise.start_handshake()
164
+ self.ws.send_bytes(bytes(noise.write_message(b"")))
165
+ m2, _ = self.ws.recv()
166
+ noise.read_message(bytes(m2))
167
+ self.ws.send_bytes(bytes(noise.write_message(b"")))
168
+ assert noise.handshake_finished
169
+ self.noise = noise
170
+ self.stream = 1
171
+ self._send_lock = threading.Lock()
172
+ self._recv_lock = threading.Lock()
173
+
174
+ # -- low-level framing -------------------------------------------------
175
+ def _send_envelope(self, service_id, frame_bytes):
176
+ outer = ServiceRequest(service=service_id, payload=frame_bytes)
177
+ chunk_id = struct.unpack("<q", os.urandom(8))[0]
178
+ fr = NoiseTransportFrame(chunk_id=chunk_id, chunk_index=0, total_chunks=1,
179
+ payload=outer.SerializeToString())
180
+ with self._send_lock:
181
+ self.ws.send_bytes(bytes(self.noise.encrypt(fr.SerializeToString())))
182
+
183
+ def _read_frame(self):
184
+ # Serialized so two threads can never interleave ws.recv / Noise
185
+ # decrypt (which corrupts the transport state -> BAD_DECRYPT).
186
+ # Callers must still avoid *logical* races: only one thread should
187
+ # be consuming frames at a time, or responses get misrouted.
188
+ with self._recv_lock:
189
+ data, _flags = self.ws.recv()
190
+ pt = bytes(self.noise.decrypt(bytes(data)))
191
+ ntf = NoiseTransportFrame()
192
+ ntf.ParseFromString(pt)
193
+ sr = ServiceResponse()
194
+ sr.ParseFromString(ntf.payload)
195
+ sf = ServiceFrame()
196
+ sf.ParseFromString(sr.payload)
197
+ return sf
198
+
199
+ def _open(self, method, path_params=None, body=None, query=None):
200
+ route = ROUTES[method]
201
+ path = route["path"]
202
+ for k, v in (path_params or {}).items():
203
+ path = path.replace("{" + k + "}", urllib.parse.quote(str(v), safe=""))
204
+ if query:
205
+ qs = urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
206
+ if qs:
207
+ path = path + ("&" if "?" in path else "?") + qs
208
+ raw = b"" if body is None else (body if isinstance(body, bytes) else json.dumps(body).encode())
209
+ req = ApplicationRequest(verb=route["http"], path=path, body=raw, end_body=True)
210
+ h = req.headers.add()
211
+ h.key = "x-request-id"
212
+ h.value = str(uuid.uuid4())
213
+ if raw:
214
+ h2 = req.headers.add()
215
+ h2.key = "content-type"
216
+ h2.value = "application/json"
217
+ frame = ServiceFrame(stream_id=self.stream)
218
+ frame.request.CopyFrom(req)
219
+ sid = self.stream
220
+ self.stream += 1
221
+ self._send_envelope(SERVICE_IDS.get(route.get("service", "daemon"), 0),
222
+ frame.SerializeToString())
223
+ return sid
224
+
225
+ # -- unary request/response --------------------------------------------
226
+ def request(self, method, path_params=None, body=None, query=None, timeout=30):
227
+ route = ROUTES[method]
228
+ if route["http"] == "GET" and body is not None and query is None:
229
+ query, body = body, None
230
+ sid = self._open(method, path_params, body, query)
231
+ status, chunks, deadline = None, [], time.time() + timeout
232
+ while time.time() < deadline:
233
+ sf = self._read_frame()
234
+ if sf.stream_id != sid:
235
+ continue
236
+ kind = sf.WhichOneof("kind")
237
+ if kind == "response":
238
+ status = sf.response.status
239
+ if sf.response.body:
240
+ chunks.append(bytes(sf.response.body))
241
+ if sf.response.end_body:
242
+ break
243
+ elif kind == "body_chunk":
244
+ chunks.append(bytes(sf.body_chunk.data))
245
+ if sf.body_chunk.end_body:
246
+ break
247
+ elif kind == "reset":
248
+ raise GatewayError(-1, f"stream reset {sf.reset.code}: {sf.reset.reason}")
249
+ if status is None:
250
+ raise TimeoutError(f"no response for {method}")
251
+ payload = b"".join(chunks)
252
+ if status < 200 or status >= 300:
253
+ raise GatewayError(status, payload[:500].decode("utf-8", "replace"))
254
+ return payload
255
+
256
+ def call_json(self, method, path_params=None, body=None, query=None, timeout=30):
257
+ raw = self.request(method, path_params, body, query, timeout)
258
+ try:
259
+ d = json.loads(raw) if raw else {}
260
+ except json.JSONDecodeError:
261
+ raise GatewayError(-1, f"non-JSON response: {raw[:200]!r}")
262
+ if isinstance(d, dict) and d.get("ok") is False:
263
+ raise GatewayError(-1, f"api error: {d.get('error')}")
264
+ return d.get("result", d) if isinstance(d, dict) else d
265
+
266
+ # -- subscriptions (ndjson event streams) -------------------------------
267
+ def subscribe_raw(self, method, path_params=None, body=None, query=None,
268
+ max_records=100, idle_timeout=10, overall_timeout=120):
269
+ """Open a subscription; returns list of raw record bytes."""
270
+ sid = self._open(method, path_params, body, query)
271
+ buf, records = b"", []
272
+ q: queue_mod.Queue = queue_mod.Queue()
273
+ stop = False
274
+
275
+ def reader():
276
+ try:
277
+ while not stop:
278
+ q.put(self._read_frame())
279
+ except Exception as e: # noqa: BLE001
280
+ q.put(e)
281
+
282
+ threading.Thread(target=reader, daemon=True).start()
283
+ deadline = time.time() + overall_timeout
284
+ idle = time.time() + idle_timeout
285
+ try:
286
+ while time.time() < deadline and len(records) < max_records:
287
+ try:
288
+ sf = q.get(timeout=2)
289
+ except queue_mod.Empty:
290
+ if time.time() > idle:
291
+ break
292
+ continue
293
+ if isinstance(sf, Exception):
294
+ raise sf
295
+ if sf.stream_id != sid:
296
+ continue
297
+ idle = time.time() + idle_timeout
298
+ kind = sf.WhichOneof("kind")
299
+ if kind == "response":
300
+ if sf.response.body:
301
+ buf += bytes(sf.response.body)
302
+ if sf.response.end_body and not buf:
303
+ break
304
+ elif kind == "body_chunk":
305
+ buf += bytes(sf.body_chunk.data)
306
+ if sf.body_chunk.end_body:
307
+ break
308
+ elif kind == "reset":
309
+ break
310
+ while b"\n" in buf:
311
+ line, buf = buf.split(b"\n", 1)
312
+ if line.strip():
313
+ records.append(line)
314
+ if len(records) >= max_records:
315
+ break
316
+ finally:
317
+ nonlocal_stop = True
318
+ stop = nonlocal_stop
319
+ if buf.strip():
320
+ records.append(buf)
321
+ return records
322
+
323
+ def subscribe_json(self, *a, **kw):
324
+ out = []
325
+ for line in self.subscribe_raw(*a, **kw):
326
+ try:
327
+ out.append(json.loads(line))
328
+ except json.JSONDecodeError:
329
+ continue
330
+ return out
331
+
332
+ def close(self):
333
+ try:
334
+ self.ws.close()
335
+ except Exception: # noqa: BLE001
336
+ pass