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/__init__.py +3 -0
- muse_cli/__main__.py +3 -0
- muse_cli/cli.py +538 -0
- muse_cli/desc0.bin +0 -0
- muse_cli/desc1.bin +0 -0
- muse_cli/gateway.py +336 -0
- muse_cli/routes.json +1410 -0
- muse_cli-0.2.0.dist-info/METADATA +195 -0
- muse_cli-0.2.0.dist-info/RECORD +12 -0
- muse_cli-0.2.0.dist-info/WHEEL +4 -0
- muse_cli-0.2.0.dist-info/entry_points.txt +2 -0
- muse_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|