mtrx-cli 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_ca.py +257 -39
- package/src/matrx/cli/cursor_config.py +14 -1
- package/src/matrx/cli/cursor_daemon.py +4 -0
- package/src/matrx/cli/cursor_launcher.py +3 -1
- package/src/matrx/cli/cursor_proxy.py +412 -166
- package/src/matrx/cli/cursor_reroute.py +376 -17
- package/src/matrx/cli/launcher.py +47 -1
- package/src/matrx/cli/main.py +384 -59
- package/src/matrx/cli/state.py +21 -0
|
@@ -19,23 +19,33 @@ Design choices (informed by cursor-tap):
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import asyncio
|
|
22
|
+
import contextlib
|
|
22
23
|
import logging
|
|
23
24
|
import os
|
|
24
25
|
import signal
|
|
25
26
|
import ssl
|
|
27
|
+
import sys
|
|
26
28
|
import time
|
|
27
29
|
import uuid
|
|
28
30
|
from pathlib import Path
|
|
29
|
-
from typing import Any
|
|
31
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
|
30
32
|
|
|
31
33
|
import httpx
|
|
32
34
|
|
|
33
35
|
from matrx.cli.cursor_ca import CertCache, load_ca
|
|
34
36
|
|
|
35
37
|
try:
|
|
36
|
-
from matrx.cli.cursor_reroute import
|
|
38
|
+
from matrx.cli.cursor_reroute import (
|
|
39
|
+
classify_ai_request,
|
|
40
|
+
is_ai_path,
|
|
41
|
+
try_inject_context,
|
|
42
|
+
try_reroute_to_matrx,
|
|
43
|
+
)
|
|
37
44
|
except ImportError:
|
|
38
45
|
# Stubs when cursor_reroute not available (e.g. npm package omit).
|
|
46
|
+
def classify_ai_request(method: str, path: str, headers: dict[str, str] | None = None) -> dict[str, bool]:
|
|
47
|
+
return {"candidate": False, "reroutable": False}
|
|
48
|
+
|
|
39
49
|
def is_ai_path(path: str) -> bool:
|
|
40
50
|
return False
|
|
41
51
|
|
|
@@ -47,19 +57,74 @@ except ImportError:
|
|
|
47
57
|
|
|
48
58
|
logger = logging.getLogger(__name__)
|
|
49
59
|
|
|
60
|
+
|
|
61
|
+
class _SuppressAsyncioNoise(logging.Filter):
|
|
62
|
+
"""Suppress known-benign asyncio noise on Windows and SSL connections.
|
|
63
|
+
|
|
64
|
+
Two cases are filtered:
|
|
65
|
+
|
|
66
|
+
1. SSL EOF warning — Python's asyncio SSL transport emits this at WARNING
|
|
67
|
+
level whenever a remote peer closes the connection. The return value
|
|
68
|
+
from eof_received() is silently ignored for SSL connections; harmless
|
|
69
|
+
and unfixable without rewriting asyncio's SSL transport layer.
|
|
70
|
+
|
|
71
|
+
2. WinError 10054 in _call_connection_lost — Windows ProactorEventLoop
|
|
72
|
+
calls socket.shutdown(SHUT_RDWR) on already-reset sockets during
|
|
73
|
+
connection teardown. Cursor's CodebaseSnapshotService packfile uploads
|
|
74
|
+
close connections with TCP RST, triggering this path. The error is
|
|
75
|
+
logged at ERROR level by asyncio but indicates normal connection teardown;
|
|
76
|
+
the proxy's own logic is unaffected.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
80
|
+
msg = record.getMessage()
|
|
81
|
+
if "eof_received" in msg:
|
|
82
|
+
return False
|
|
83
|
+
if "_call_connection_lost" in msg and record.exc_info:
|
|
84
|
+
exc_type = record.exc_info[0]
|
|
85
|
+
if exc_type is not None and issubclass(exc_type, ConnectionResetError):
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_MAX_BODY_BYTES = 50 * 1024 * 1024 # 50 MB hard limit for buffered request bodies
|
|
91
|
+
|
|
50
92
|
DEFAULT_PORT = 8842
|
|
51
93
|
PROXY_HOST = "127.0.0.1"
|
|
52
94
|
HEALTH_PATH = "/__mtrx_health__"
|
|
53
95
|
|
|
96
|
+
|
|
97
|
+
def _print_inbox_notification(from_agent_id: str, prompt_preview: str) -> None:
|
|
98
|
+
"""Print a visible terminal notification when an A2A task arrives while idle."""
|
|
99
|
+
preview_display = f': "{prompt_preview}"' if prompt_preview else ""
|
|
100
|
+
print(
|
|
101
|
+
f"\n\033[1m[MTRX]\033[0m \U0001f4ec A2A task from {from_agent_id}{preview_display}"
|
|
102
|
+
"\n → Start a new turn to receive and process it.\n",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
flush=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
54
107
|
# Domains whose TLS we intercept for observability.
|
|
55
108
|
_INTERCEPT_DOMAINS = {
|
|
56
109
|
"api2.cursor.sh",
|
|
57
110
|
"api3.cursor.sh",
|
|
58
111
|
"api4.cursor.sh",
|
|
59
112
|
"api5.cursor.sh",
|
|
60
|
-
"
|
|
113
|
+
"agent.api5.cursor.sh", # Cloud Agent (privacy mode)
|
|
114
|
+
"agentn.api5.cursor.sh", # Cloud Agent (non-privacy mode)
|
|
115
|
+
"api.anthropic.com",
|
|
116
|
+
"api.openai.com",
|
|
61
117
|
}
|
|
62
118
|
|
|
119
|
+
_PREWARM_DOMAINS = (
|
|
120
|
+
"api2.cursor.sh",
|
|
121
|
+
"api3.cursor.sh",
|
|
122
|
+
"api4.cursor.sh",
|
|
123
|
+
"api5.cursor.sh",
|
|
124
|
+
"agent.api5.cursor.sh",
|
|
125
|
+
"agentn.api5.cursor.sh",
|
|
126
|
+
)
|
|
127
|
+
|
|
63
128
|
|
|
64
129
|
class MITMProxy:
|
|
65
130
|
"""Async MITM forward proxy with telemetry mirroring."""
|
|
@@ -71,24 +136,41 @@ class MITMProxy:
|
|
|
71
136
|
matrx_base_url: str,
|
|
72
137
|
host: str = PROXY_HOST,
|
|
73
138
|
port: int = DEFAULT_PORT,
|
|
139
|
+
agent_id: str | None = None,
|
|
140
|
+
group_id: str | None = None,
|
|
74
141
|
):
|
|
75
142
|
self.matrx_key = matrx_key
|
|
76
143
|
self.matrx_base_url = matrx_base_url.rstrip("/")
|
|
77
144
|
self.host = host
|
|
78
145
|
self.port = port
|
|
146
|
+
self._agent_id: str | None = agent_id
|
|
147
|
+
self._group_id: str | None = group_id
|
|
79
148
|
self._server: asyncio.Server | None = None
|
|
80
149
|
self._telemetry_client: httpx.AsyncClient | None = None
|
|
81
150
|
self._cert_cache: CertCache | None = None
|
|
82
151
|
self._request_count = 0
|
|
152
|
+
self._connect_count = 0
|
|
153
|
+
self._inbox_poll_task: asyncio.Task | None = None
|
|
83
154
|
|
|
84
155
|
async def start(self) -> None:
|
|
85
156
|
ca_key, ca_cert = load_ca()
|
|
86
157
|
self._cert_cache = CertCache(ca_key, ca_cert)
|
|
158
|
+
self._cert_cache.prewarm(_PREWARM_DOMAINS)
|
|
87
159
|
self._telemetry_client = httpx.AsyncClient(timeout=10)
|
|
88
160
|
self._server = await asyncio.start_server(
|
|
89
161
|
self._handle_client, self.host, self.port
|
|
90
162
|
)
|
|
91
163
|
logger.info("MITM proxy listening on %s:%d", self.host, self.port)
|
|
164
|
+
if self._agent_id and self._group_id:
|
|
165
|
+
self._inbox_poll_task = asyncio.create_task(
|
|
166
|
+
self._run_inbox_poll_loop(),
|
|
167
|
+
name="mtrx-inbox-poll",
|
|
168
|
+
)
|
|
169
|
+
logger.info(
|
|
170
|
+
"proxy: inbox poller started agent_id=%s group_id=%s",
|
|
171
|
+
self._agent_id,
|
|
172
|
+
self._group_id,
|
|
173
|
+
)
|
|
92
174
|
|
|
93
175
|
async def serve_forever(self) -> None:
|
|
94
176
|
if self._server is None:
|
|
@@ -98,6 +180,10 @@ class MITMProxy:
|
|
|
98
180
|
await self._server.serve_forever()
|
|
99
181
|
|
|
100
182
|
async def stop(self) -> None:
|
|
183
|
+
if self._inbox_poll_task and not self._inbox_poll_task.done():
|
|
184
|
+
self._inbox_poll_task.cancel()
|
|
185
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
186
|
+
await self._inbox_poll_task
|
|
101
187
|
if self._server:
|
|
102
188
|
self._server.close()
|
|
103
189
|
await self._server.wait_closed()
|
|
@@ -108,6 +194,58 @@ class MITMProxy:
|
|
|
108
194
|
def request_count(self) -> int:
|
|
109
195
|
return self._request_count
|
|
110
196
|
|
|
197
|
+
# -----------------------------------------------------------------
|
|
198
|
+
# Inbox background poller
|
|
199
|
+
# -----------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def _run_inbox_poll_loop(self) -> None:
|
|
202
|
+
"""Long-poll /v1/inbox/wait while the proxy is running.
|
|
203
|
+
|
|
204
|
+
When a directed work item arrives for this agent, prints a visible
|
|
205
|
+
terminal notification so the user knows to give the agent its next
|
|
206
|
+
turn. Actual task delivery still happens via the normal injection
|
|
207
|
+
path (claim_directed_work_for_injection) on the next proxy call —
|
|
208
|
+
this loop only provides the push notification.
|
|
209
|
+
"""
|
|
210
|
+
url = f"{self.matrx_base_url}/v1/inbox/wait"
|
|
211
|
+
params: dict[str, str | int] = {
|
|
212
|
+
"group_id": str(self._group_id),
|
|
213
|
+
"timeout_s": 25,
|
|
214
|
+
}
|
|
215
|
+
if self._agent_id:
|
|
216
|
+
params["agent_id"] = self._agent_id
|
|
217
|
+
headers = {"X-Matrx-Key": self.matrx_key}
|
|
218
|
+
|
|
219
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(32.0, connect=5.0)) as client:
|
|
220
|
+
while True:
|
|
221
|
+
try:
|
|
222
|
+
resp = await client.get(url, params=params, headers=headers)
|
|
223
|
+
if resp.status_code == 200:
|
|
224
|
+
data = resp.json()
|
|
225
|
+
if data.get("has_pending"):
|
|
226
|
+
from_label = data.get("from_agent_id") or "external"
|
|
227
|
+
preview = (data.get("prompt_preview") or "").strip()
|
|
228
|
+
_print_inbox_notification(from_label, preview)
|
|
229
|
+
elif resp.status_code == 401:
|
|
230
|
+
logger.warning("proxy: inbox poller received 401 — stopping")
|
|
231
|
+
return
|
|
232
|
+
# Any other non-2xx: log at debug and retry after backoff
|
|
233
|
+
elif resp.status_code >= 400:
|
|
234
|
+
logger.debug(
|
|
235
|
+
"proxy: inbox poller got %s, retrying in 10s",
|
|
236
|
+
resp.status_code,
|
|
237
|
+
)
|
|
238
|
+
await asyncio.sleep(10)
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
return
|
|
241
|
+
except (httpx.TimeoutException, httpx.ConnectError):
|
|
242
|
+
# Timeout is expected (server held 25s with no work).
|
|
243
|
+
# ConnectError happens briefly at startup or on network blip.
|
|
244
|
+
pass
|
|
245
|
+
except Exception:
|
|
246
|
+
logger.debug("proxy: inbox poller error", exc_info=True)
|
|
247
|
+
await asyncio.sleep(5)
|
|
248
|
+
|
|
111
249
|
# -----------------------------------------------------------------
|
|
112
250
|
# Connection handling
|
|
113
251
|
# -----------------------------------------------------------------
|
|
@@ -122,7 +260,7 @@ class MITMProxy:
|
|
|
122
260
|
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
|
123
261
|
pass
|
|
124
262
|
except Exception:
|
|
125
|
-
logger.
|
|
263
|
+
logger.warning("proxy: connection error", exc_info=True)
|
|
126
264
|
finally:
|
|
127
265
|
try:
|
|
128
266
|
writer.close()
|
|
@@ -166,8 +304,10 @@ class MITMProxy:
|
|
|
166
304
|
await writer.drain()
|
|
167
305
|
|
|
168
306
|
if hostname in _INTERCEPT_DOMAINS:
|
|
307
|
+
logger.info("proxy: CONNECT %s:%d [intercept]", hostname, port)
|
|
169
308
|
await self._mitm_intercept(reader, writer, hostname, port)
|
|
170
309
|
else:
|
|
310
|
+
logger.info("proxy: CONNECT %s:%d [tunnel]", hostname, port)
|
|
171
311
|
await self._tunnel_passthrough(reader, writer, hostname, port)
|
|
172
312
|
elif method in ("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"):
|
|
173
313
|
# Plain HTTP proxy request (non-CONNECT) -- handle health check
|
|
@@ -218,10 +358,22 @@ class MITMProxy:
|
|
|
218
358
|
port: int,
|
|
219
359
|
) -> None:
|
|
220
360
|
assert self._cert_cache is not None
|
|
361
|
+
self._connect_count += 1
|
|
362
|
+
conn_id = f"{hostname}:{self._connect_count}"
|
|
221
363
|
|
|
222
364
|
# Use the hostname from the CONNECT request for the cert
|
|
223
365
|
# (matches SNI in virtually all cases, avoids ClientHello peeking)
|
|
366
|
+
handshake_info = self._cert_cache.get_handshake_info(hostname)
|
|
224
367
|
server_ctx = self._cert_cache.get_ssl_context(hostname)
|
|
368
|
+
logger.info(
|
|
369
|
+
"proxy: tls_prepare conn=%s host=%s serial=%s leaf_sha256=%s chain_len=%s cert=%s",
|
|
370
|
+
conn_id,
|
|
371
|
+
hostname,
|
|
372
|
+
handshake_info["leaf_serial"],
|
|
373
|
+
handshake_info["leaf_sha256"],
|
|
374
|
+
handshake_info["chain_length"],
|
|
375
|
+
handshake_info["cert_path"],
|
|
376
|
+
)
|
|
225
377
|
|
|
226
378
|
# Upgrade client connection to TLS (we are the "server")
|
|
227
379
|
loop = asyncio.get_running_loop()
|
|
@@ -232,8 +384,23 @@ class MITMProxy:
|
|
|
232
384
|
transport, protocol, server_ctx, server_side=True
|
|
233
385
|
)
|
|
234
386
|
except (ssl.SSLError, ConnectionError) as exc:
|
|
235
|
-
logger.
|
|
387
|
+
logger.warning(
|
|
388
|
+
"TLS handshake with client failed for %s [conn=%s serial=%s leaf_sha256=%s chain_len=%s]: %s",
|
|
389
|
+
hostname,
|
|
390
|
+
conn_id,
|
|
391
|
+
handshake_info["leaf_serial"],
|
|
392
|
+
handshake_info["leaf_sha256"],
|
|
393
|
+
handshake_info["chain_length"],
|
|
394
|
+
exc,
|
|
395
|
+
)
|
|
236
396
|
return
|
|
397
|
+
logger.info(
|
|
398
|
+
"proxy: tls_ready conn=%s host=%s serial=%s chain_len=%s",
|
|
399
|
+
conn_id,
|
|
400
|
+
hostname,
|
|
401
|
+
handshake_info["leaf_serial"],
|
|
402
|
+
handshake_info["chain_length"],
|
|
403
|
+
)
|
|
237
404
|
|
|
238
405
|
tls_writer = asyncio.StreamWriter(new_transport, protocol, client_reader, loop)
|
|
239
406
|
|
|
@@ -246,6 +413,11 @@ class MITMProxy:
|
|
|
246
413
|
)
|
|
247
414
|
except Exception:
|
|
248
415
|
logger.debug("Failed to connect to upstream %s:%d", hostname, port)
|
|
416
|
+
try:
|
|
417
|
+
tls_writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
|
418
|
+
await tls_writer.drain()
|
|
419
|
+
except Exception:
|
|
420
|
+
pass
|
|
249
421
|
return
|
|
250
422
|
|
|
251
423
|
# Forward HTTP/1.1 traffic between decrypted client and upstream
|
|
@@ -280,9 +452,11 @@ class MITMProxy:
|
|
|
280
452
|
while True:
|
|
281
453
|
req_line = await client_reader.readline()
|
|
282
454
|
if not req_line:
|
|
455
|
+
logger.info("proxy: %s — connection closed (no request line)", hostname)
|
|
283
456
|
break
|
|
284
457
|
req_line_str = req_line.decode("utf-8", errors="replace").strip()
|
|
285
458
|
if not req_line_str:
|
|
459
|
+
logger.info("proxy: %s — empty request line", hostname)
|
|
286
460
|
break
|
|
287
461
|
|
|
288
462
|
parts = req_line_str.split(" ", 2)
|
|
@@ -290,64 +464,109 @@ class MITMProxy:
|
|
|
290
464
|
path = parts[1] if len(parts) > 1 else "/"
|
|
291
465
|
|
|
292
466
|
req_body_size = 0
|
|
293
|
-
_is_ai_req =
|
|
294
|
-
_req_session_id =
|
|
295
|
-
|
|
296
|
-
|
|
467
|
+
_is_ai_req = False
|
|
468
|
+
_req_session_id = ""
|
|
469
|
+
req_headers: dict[str, str]
|
|
470
|
+
req_cl: int
|
|
471
|
+
req_chunked: bool
|
|
472
|
+
|
|
473
|
+
if method == "POST":
|
|
297
474
|
req_headers, req_cl, req_chunked = await self._read_headers_only(
|
|
298
475
|
client_reader
|
|
299
476
|
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
method
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
477
|
+
ai_classification = classify_ai_request(method, path, req_headers)
|
|
478
|
+
_is_ai_req = ai_classification["candidate"]
|
|
479
|
+
_is_ai_reroutable = ai_classification["reroutable"]
|
|
480
|
+
_req_session_id = str(uuid.uuid4()) if _is_ai_req else ""
|
|
481
|
+
logger.info(
|
|
482
|
+
"proxy: %s %s%s [ai=%s reroutable=%s ct=%s]",
|
|
483
|
+
method,
|
|
484
|
+
hostname,
|
|
485
|
+
path,
|
|
486
|
+
_is_ai_req,
|
|
487
|
+
_is_ai_reroutable,
|
|
488
|
+
req_headers.get("content-type", ""),
|
|
312
489
|
)
|
|
313
|
-
if
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
490
|
+
if _is_ai_req and not _is_ai_reroutable and "aiserver.v1." in path.lower():
|
|
491
|
+
logger.info("proxy: candidate AI request not yet reroutable: %s%s", hostname, path)
|
|
492
|
+
|
|
493
|
+
# For AI paths: buffer request and try rerouting through MTRX (live injection)
|
|
494
|
+
if _is_ai_req:
|
|
495
|
+
try:
|
|
496
|
+
req_body = await self._read_body_to_bytes(
|
|
497
|
+
client_reader, req_cl, req_chunked
|
|
319
498
|
)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
499
|
+
except ValueError:
|
|
500
|
+
client_writer.write(b"HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\n\r\n")
|
|
501
|
+
await client_writer.drain()
|
|
502
|
+
return
|
|
503
|
+
req_body_size = len(req_body)
|
|
504
|
+
result = await try_reroute_to_matrx(
|
|
505
|
+
path=path,
|
|
506
|
+
method=method,
|
|
507
|
+
req_headers=req_headers,
|
|
508
|
+
req_body=req_body,
|
|
509
|
+
matrx_base_url=self.matrx_base_url,
|
|
510
|
+
matrx_key=self.matrx_key,
|
|
511
|
+
session_id=_req_session_id,
|
|
512
|
+
)
|
|
513
|
+
if result is not None:
|
|
514
|
+
success, resp_headers, resp_body, is_streaming = result
|
|
515
|
+
if success and resp_body is not None:
|
|
516
|
+
self._request_count += 1
|
|
517
|
+
if hasattr(resp_body, "__aiter__"):
|
|
518
|
+
# Streaming generator: write chunked HTTP response
|
|
519
|
+
resp_body_size = await self._write_chunked_reroute_response(
|
|
520
|
+
client_writer, resp_headers, resp_body
|
|
521
|
+
)
|
|
522
|
+
else:
|
|
523
|
+
# Buffered bytes: write with content-length
|
|
524
|
+
self._write_http_response(
|
|
525
|
+
client_writer, 200, resp_headers, resp_body
|
|
526
|
+
)
|
|
527
|
+
await client_writer.drain()
|
|
528
|
+
resp_body_size = len(resp_body)
|
|
529
|
+
asyncio.create_task(
|
|
530
|
+
self._ship_telemetry(
|
|
531
|
+
hostname=hostname,
|
|
532
|
+
method=method,
|
|
533
|
+
path=path,
|
|
534
|
+
status_code=200,
|
|
535
|
+
req_body_size=len(req_body),
|
|
536
|
+
resp_body_size=resp_body_size,
|
|
537
|
+
elapsed_ms=0,
|
|
538
|
+
content_type=resp_headers.get("content-type", ""),
|
|
539
|
+
is_streaming=is_streaming,
|
|
540
|
+
)
|
|
331
541
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
#
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
542
|
+
continue
|
|
543
|
+
# Reroute returned but failed — fall through to forward
|
|
544
|
+
# Inject MTRX memory context into request before forwarding
|
|
545
|
+
injected_body = await try_inject_context(
|
|
546
|
+
req_body=req_body,
|
|
547
|
+
req_headers=req_headers,
|
|
548
|
+
matrx_base_url=self.matrx_base_url,
|
|
549
|
+
matrx_key=self.matrx_key,
|
|
550
|
+
session_id=_req_session_id,
|
|
551
|
+
)
|
|
552
|
+
body_to_forward = injected_body if injected_body is not None else req_body
|
|
553
|
+
fwd_headers = dict(req_headers)
|
|
554
|
+
fwd_headers.pop("transfer-encoding", None) # remove chunked before setting content-length
|
|
555
|
+
fwd_headers["content-length"] = str(len(body_to_forward))
|
|
556
|
+
up_writer.write(req_line)
|
|
557
|
+
self._write_headers(up_writer, fwd_headers)
|
|
558
|
+
up_writer.write(body_to_forward)
|
|
559
|
+
await up_writer.drain()
|
|
560
|
+
else:
|
|
561
|
+
up_writer.write(req_line)
|
|
562
|
+
self._write_headers(up_writer, req_headers)
|
|
563
|
+
req_body_size = await self._forward_body(
|
|
564
|
+
client_reader, up_writer, req_cl, req_chunked
|
|
565
|
+
)
|
|
566
|
+
if req_body_size == 0 and req_cl > 0:
|
|
567
|
+
req_body_size = req_cl
|
|
350
568
|
else:
|
|
569
|
+
logger.info("proxy: %s %s%s [ai=%s]", method, hostname, path, False)
|
|
351
570
|
up_writer.write(req_line)
|
|
352
571
|
req_headers, req_cl, req_chunked = await self._forward_headers(
|
|
353
572
|
client_reader, up_writer
|
|
@@ -402,24 +621,22 @@ class MITMProxy:
|
|
|
402
621
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
403
622
|
self._request_count += 1
|
|
404
623
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
624
|
+
if _is_ai_req: # backend rejects telemetry for non-AI infrastructure paths
|
|
625
|
+
asyncio.create_task(
|
|
626
|
+
self._ship_telemetry(
|
|
627
|
+
hostname=hostname,
|
|
628
|
+
method=method,
|
|
629
|
+
path=path,
|
|
630
|
+
status_code=status_code,
|
|
631
|
+
req_body_size=req_body_size,
|
|
632
|
+
resp_body_size=resp_body_size,
|
|
633
|
+
elapsed_ms=elapsed_ms,
|
|
634
|
+
content_type=content_type,
|
|
635
|
+
is_streaming=is_streaming,
|
|
636
|
+
)
|
|
416
637
|
)
|
|
417
|
-
)
|
|
418
638
|
|
|
419
|
-
conn_h = (
|
|
420
|
-
req_headers.get("connection", "")
|
|
421
|
-
+ resp_headers.get("connection", "")
|
|
422
|
-
).lower()
|
|
639
|
+
conn_h = resp_headers.get("connection", "").lower()
|
|
423
640
|
if "close" in conn_h:
|
|
424
641
|
break
|
|
425
642
|
|
|
@@ -487,7 +704,25 @@ class MITMProxy:
|
|
|
487
704
|
await writer.drain()
|
|
488
705
|
return total, b"".join(parts)
|
|
489
706
|
|
|
490
|
-
|
|
707
|
+
# No content-length, no chunked encoding — stream until the upstream closes.
|
|
708
|
+
# This covers Cursor's SSE AI responses that use raw HTTP/1.1 keep-alive streaming.
|
|
709
|
+
# Cap capture at 512 KB to bound memory; bytes beyond that are still forwarded.
|
|
710
|
+
_CAPTURE_LIMIT = 512 * 1024
|
|
711
|
+
parts = []
|
|
712
|
+
total = 0
|
|
713
|
+
capturing = True
|
|
714
|
+
while True:
|
|
715
|
+
chunk = await reader.read(65536)
|
|
716
|
+
if not chunk:
|
|
717
|
+
break
|
|
718
|
+
writer.write(chunk)
|
|
719
|
+
await writer.drain()
|
|
720
|
+
total += len(chunk)
|
|
721
|
+
if capturing:
|
|
722
|
+
parts.append(chunk)
|
|
723
|
+
if total >= _CAPTURE_LIMIT:
|
|
724
|
+
capturing = False
|
|
725
|
+
return total, b"".join(parts)
|
|
491
726
|
|
|
492
727
|
async def _extract_ai_response(
|
|
493
728
|
self,
|
|
@@ -497,110 +732,63 @@ class MITMProxy:
|
|
|
497
732
|
) -> None:
|
|
498
733
|
"""Parse Connect frames from *resp_bytes* and ship response telemetry.
|
|
499
734
|
|
|
735
|
+
Tries compiled proto parsing first; falls back to raw wire-format parsing
|
|
736
|
+
so token counts are always extracted even without compiled proto files.
|
|
500
737
|
Fire-and-forget — never raises, never blocks the forward path.
|
|
501
738
|
"""
|
|
502
739
|
try:
|
|
503
|
-
from matrx.cli.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
740
|
+
from matrx.cli.cursor_extraction import ship_ai_telemetry
|
|
741
|
+
|
|
742
|
+
import gzip as _gzip
|
|
743
|
+
body = resp_bytes
|
|
744
|
+
if len(body) >= 2 and body[:2] == b"\x1f\x8b":
|
|
745
|
+
try:
|
|
746
|
+
body = _gzip.decompress(body)
|
|
747
|
+
except Exception:
|
|
748
|
+
body = resp_bytes
|
|
509
749
|
|
|
510
|
-
frames = parse_all_frames(resp_bytes)
|
|
511
750
|
accumulated: dict = {
|
|
512
751
|
"session_id": session_id,
|
|
513
752
|
"response_text": "",
|
|
514
753
|
"tool_calls": [],
|
|
515
754
|
"usage": None,
|
|
516
755
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
756
|
+
|
|
757
|
+
if hostname == "api.anthropic.com":
|
|
758
|
+
from matrx.cli.cursor_extraction import extract_from_anthropic_sse_response
|
|
759
|
+
frame_data = extract_from_anthropic_sse_response(body)
|
|
760
|
+
accumulated["response_text"] = frame_data.get("text", "")
|
|
761
|
+
accumulated["tool_calls"] = frame_data.get("tool_calls", [])
|
|
762
|
+
accumulated["usage"] = frame_data.get("usage")
|
|
763
|
+
elif hostname == "api.openai.com":
|
|
764
|
+
from matrx.cli.cursor_extraction import extract_from_openai_sse_response
|
|
765
|
+
frame_data = extract_from_openai_sse_response(body)
|
|
766
|
+
accumulated["response_text"] = frame_data.get("text", "")
|
|
767
|
+
accumulated["tool_calls"] = frame_data.get("tool_calls", [])
|
|
768
|
+
accumulated["usage"] = frame_data.get("usage")
|
|
769
|
+
else:
|
|
770
|
+
# Cursor backend: Connect/gRPC protobuf frames
|
|
771
|
+
from matrx.cli.cursor_connect import parse_all_frames
|
|
772
|
+
from matrx.cli.cursor_extraction import (
|
|
773
|
+
_raw_extract_response_frame,
|
|
774
|
+
extract_from_response_frame,
|
|
775
|
+
parse_response_proto,
|
|
776
|
+
)
|
|
777
|
+
for flags, payload in parse_all_frames(body):
|
|
778
|
+
if flags == 0x02:
|
|
779
|
+
break
|
|
780
|
+
resp_proto = parse_response_proto(payload)
|
|
781
|
+
frame_data = extract_from_response_frame(resp_proto) if resp_proto is not None else _raw_extract_response_frame(payload)
|
|
782
|
+
if frame_data:
|
|
783
|
+
accumulated["response_text"] += frame_data.get("text", "")
|
|
784
|
+
accumulated["tool_calls"].extend(frame_data.get("tool_calls", []))
|
|
785
|
+
if frame_data.get("usage"):
|
|
786
|
+
accumulated["usage"] = frame_data["usage"]
|
|
529
787
|
|
|
530
788
|
await ship_ai_telemetry(accumulated, self.matrx_base_url, self.matrx_key)
|
|
531
789
|
except Exception:
|
|
532
790
|
logger.debug("proxy: _extract_ai_response failed", exc_info=True)
|
|
533
791
|
|
|
534
|
-
async def _read_headers_only(
|
|
535
|
-
self, reader: asyncio.StreamReader
|
|
536
|
-
) -> tuple[dict[str, str], int, bool]:
|
|
537
|
-
"""Read headers without writing. Returns (headers_dict, content_length, is_chunked)."""
|
|
538
|
-
headers: dict[str, str] = {}
|
|
539
|
-
content_length = -1
|
|
540
|
-
chunked = False
|
|
541
|
-
while True:
|
|
542
|
-
line = await reader.readline()
|
|
543
|
-
decoded = line.decode("utf-8", errors="replace").strip()
|
|
544
|
-
if not decoded:
|
|
545
|
-
break
|
|
546
|
-
if ":" in decoded:
|
|
547
|
-
k, _, v = decoded.partition(":")
|
|
548
|
-
k_lower = k.strip().lower()
|
|
549
|
-
v_stripped = v.strip()
|
|
550
|
-
headers[k_lower] = v_stripped
|
|
551
|
-
if k_lower == "content-length":
|
|
552
|
-
content_length = int(v_stripped)
|
|
553
|
-
elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
|
|
554
|
-
chunked = True
|
|
555
|
-
return headers, content_length, chunked
|
|
556
|
-
|
|
557
|
-
async def _read_body_to_bytes(
|
|
558
|
-
self,
|
|
559
|
-
reader: asyncio.StreamReader,
|
|
560
|
-
content_length: int,
|
|
561
|
-
chunked: bool,
|
|
562
|
-
) -> bytes:
|
|
563
|
-
"""Read body into bytes (no writer)."""
|
|
564
|
-
if content_length > 0:
|
|
565
|
-
return await reader.read(content_length)
|
|
566
|
-
if chunked:
|
|
567
|
-
parts: list[bytes] = []
|
|
568
|
-
while True:
|
|
569
|
-
size_line = await reader.readline()
|
|
570
|
-
size_str = size_line.decode("utf-8", errors="replace").strip()
|
|
571
|
-
try:
|
|
572
|
-
chunk_size = int(size_str.split(";")[0], 16)
|
|
573
|
-
except ValueError:
|
|
574
|
-
break
|
|
575
|
-
if chunk_size == 0:
|
|
576
|
-
await reader.readline() # trailer
|
|
577
|
-
break
|
|
578
|
-
parts.append(await reader.read(chunk_size))
|
|
579
|
-
await reader.readline() # crlf
|
|
580
|
-
return b"".join(parts)
|
|
581
|
-
return b""
|
|
582
|
-
|
|
583
|
-
def _write_headers(
|
|
584
|
-
self, writer: asyncio.StreamWriter, headers: dict[str, str]
|
|
585
|
-
) -> None:
|
|
586
|
-
"""Write headers as HTTP lines (caller must drain)."""
|
|
587
|
-
for k, v in headers.items():
|
|
588
|
-
writer.write(f"{k}: {v}\r\n".encode())
|
|
589
|
-
writer.write(b"\r\n")
|
|
590
|
-
|
|
591
|
-
def _write_http_response(
|
|
592
|
-
self,
|
|
593
|
-
writer: asyncio.StreamWriter,
|
|
594
|
-
status: int,
|
|
595
|
-
resp_headers: dict[str, str],
|
|
596
|
-
resp_body: bytes,
|
|
597
|
-
) -> None:
|
|
598
|
-
"""Write a complete HTTP response."""
|
|
599
|
-
writer.write(f"HTTP/1.1 {status} OK\r\n".encode())
|
|
600
|
-
self._write_headers(writer, resp_headers)
|
|
601
|
-
writer.write(resp_body)
|
|
602
|
-
# Caller should drain
|
|
603
|
-
|
|
604
792
|
async def _forward_headers(
|
|
605
793
|
self,
|
|
606
794
|
reader: asyncio.StreamReader,
|
|
@@ -625,7 +813,10 @@ class MITMProxy:
|
|
|
625
813
|
v_stripped = v.strip()
|
|
626
814
|
headers[k_lower] = v_stripped
|
|
627
815
|
if k_lower == "content-length":
|
|
628
|
-
|
|
816
|
+
try:
|
|
817
|
+
content_length = int(v_stripped)
|
|
818
|
+
except ValueError:
|
|
819
|
+
pass
|
|
629
820
|
elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
|
|
630
821
|
chunked = True
|
|
631
822
|
await writer.drain()
|
|
@@ -717,7 +908,10 @@ class MITMProxy:
|
|
|
717
908
|
v_stripped = v.strip()
|
|
718
909
|
headers[k_lower] = v_stripped
|
|
719
910
|
if k_lower == "content-length":
|
|
720
|
-
|
|
911
|
+
try:
|
|
912
|
+
content_length = int(v_stripped)
|
|
913
|
+
except ValueError:
|
|
914
|
+
pass
|
|
721
915
|
elif k_lower == "transfer-encoding" and "chunked" in v_stripped.lower():
|
|
722
916
|
chunked = True
|
|
723
917
|
return headers, content_length, chunked
|
|
@@ -730,6 +924,8 @@ class MITMProxy:
|
|
|
730
924
|
) -> bytes:
|
|
731
925
|
"""Read body into bytes."""
|
|
732
926
|
if content_length > 0:
|
|
927
|
+
if content_length > _MAX_BODY_BYTES:
|
|
928
|
+
raise ValueError(f"Request body too large: {content_length} bytes")
|
|
733
929
|
return await reader.readexactly(content_length)
|
|
734
930
|
if chunked:
|
|
735
931
|
parts: list[bytes] = []
|
|
@@ -774,6 +970,38 @@ class MITMProxy:
|
|
|
774
970
|
writer.write(body)
|
|
775
971
|
# Note: drain is caller's responsibility
|
|
776
972
|
|
|
973
|
+
async def _write_chunked_reroute_response(
|
|
974
|
+
self,
|
|
975
|
+
writer: asyncio.StreamWriter,
|
|
976
|
+
headers: dict[str, str],
|
|
977
|
+
frames: AsyncGenerator[bytes, None],
|
|
978
|
+
) -> int:
|
|
979
|
+
"""Write an HTTP/1.1 chunked-encoded response by iterating a Connect-frame generator.
|
|
980
|
+
|
|
981
|
+
Each Connect frame from the generator becomes one chunk. The response ends
|
|
982
|
+
with the mandatory zero-length chunk terminator. Returns total payload bytes
|
|
983
|
+
written (for telemetry).
|
|
984
|
+
"""
|
|
985
|
+
writer.write(b"HTTP/1.1 200 OK\r\n")
|
|
986
|
+
merged = dict(headers)
|
|
987
|
+
merged["transfer-encoding"] = "chunked"
|
|
988
|
+
self._write_headers(writer, merged)
|
|
989
|
+
await writer.drain()
|
|
990
|
+
|
|
991
|
+
total = 0
|
|
992
|
+
async for chunk in frames:
|
|
993
|
+
if not chunk:
|
|
994
|
+
continue
|
|
995
|
+
writer.write(f"{len(chunk):x}\r\n".encode())
|
|
996
|
+
writer.write(chunk)
|
|
997
|
+
writer.write(b"\r\n")
|
|
998
|
+
await writer.drain()
|
|
999
|
+
total += len(chunk)
|
|
1000
|
+
|
|
1001
|
+
writer.write(b"0\r\n\r\n")
|
|
1002
|
+
await writer.drain()
|
|
1003
|
+
return total
|
|
1004
|
+
|
|
777
1005
|
# -----------------------------------------------------------------
|
|
778
1006
|
# Raw bidirectional pipe (for opaque tunnels)
|
|
779
1007
|
# -----------------------------------------------------------------
|
|
@@ -820,7 +1048,7 @@ class MITMProxy:
|
|
|
820
1048
|
content_type: str,
|
|
821
1049
|
is_streaming: bool,
|
|
822
1050
|
) -> None:
|
|
823
|
-
if self._telemetry_client is None:
|
|
1051
|
+
if self._telemetry_client is None or not self.matrx_key:
|
|
824
1052
|
return
|
|
825
1053
|
|
|
826
1054
|
payload = {
|
|
@@ -837,11 +1065,19 @@ class MITMProxy:
|
|
|
837
1065
|
}
|
|
838
1066
|
url = f"{self.matrx_base_url}/v1/telemetry/cursor"
|
|
839
1067
|
try:
|
|
840
|
-
await self._telemetry_client.post(
|
|
1068
|
+
resp = await self._telemetry_client.post(
|
|
841
1069
|
url,
|
|
842
1070
|
json=payload,
|
|
843
1071
|
headers={"X-Matrx-Key": self.matrx_key},
|
|
844
1072
|
)
|
|
1073
|
+
if resp.status_code >= 400:
|
|
1074
|
+
logger.warning(
|
|
1075
|
+
"telemetry: %s from %s (key=%s... path=%s)",
|
|
1076
|
+
resp.status_code,
|
|
1077
|
+
url,
|
|
1078
|
+
self.matrx_key[:8],
|
|
1079
|
+
path,
|
|
1080
|
+
)
|
|
845
1081
|
except Exception:
|
|
846
1082
|
logger.debug("telemetry ship failed", exc_info=True)
|
|
847
1083
|
|
|
@@ -857,8 +1093,16 @@ def run_proxy(
|
|
|
857
1093
|
host: str = PROXY_HOST,
|
|
858
1094
|
port: int = DEFAULT_PORT,
|
|
859
1095
|
pid_file: Path | None = None,
|
|
1096
|
+
agent_id: str | None = None,
|
|
1097
|
+
group_id: str | None = None,
|
|
860
1098
|
) -> None:
|
|
861
1099
|
"""Run the MITM proxy (blocking). Intended for daemon/service use."""
|
|
1100
|
+
logging.getLogger("asyncio").addFilter(_SuppressAsyncioNoise())
|
|
1101
|
+
|
|
1102
|
+
# Allow agent/group identity to come from environment when not explicitly set
|
|
1103
|
+
agent_id = agent_id or os.environ.get("MTRX_AGENT_ID") or None
|
|
1104
|
+
group_id = group_id or os.environ.get("MTRX_GROUP_ID") or None
|
|
1105
|
+
|
|
862
1106
|
if pid_file:
|
|
863
1107
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
864
1108
|
pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
|
@@ -868,6 +1112,8 @@ def run_proxy(
|
|
|
868
1112
|
matrx_base_url=matrx_base_url,
|
|
869
1113
|
host=host,
|
|
870
1114
|
port=port,
|
|
1115
|
+
agent_id=agent_id,
|
|
1116
|
+
group_id=group_id,
|
|
871
1117
|
)
|
|
872
1118
|
|
|
873
1119
|
loop = asyncio.new_event_loop()
|