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.
@@ -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 is_ai_path, try_inject_context, try_reroute_to_matrx
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
- "agentn.global.api5.cursor.sh",
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.debug("proxy: connection error", exc_info=True)
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.debug("TLS handshake with client failed for %s: %s", hostname, exc)
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 = method == "POST" and is_ai_path(path)
294
- _req_session_id = str(uuid.uuid4()) if _is_ai_req else ""
295
- # For AI paths: buffer request and try rerouting through MTRX (live injection)
296
- if _is_ai_req:
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
- req_body = await self._read_body_to_bytes(
301
- client_reader, req_cl, req_chunked
302
- )
303
- req_body_size = len(req_body)
304
- result = await try_reroute_to_matrx(
305
- path=path,
306
- method=method,
307
- req_headers=req_headers,
308
- req_body=req_body,
309
- matrx_base_url=self.matrx_base_url,
310
- matrx_key=self.matrx_key,
311
- session_id=_req_session_id,
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 result is not None:
314
- success, resp_headers, resp_body, is_streaming = result
315
- if success and resp_body is not None:
316
- self._request_count += 1
317
- self._write_http_response(
318
- client_writer, 200, resp_headers, resp_body
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
- asyncio.create_task(
321
- self._ship_telemetry(
322
- hostname=hostname,
323
- method=method,
324
- path=path,
325
- status_code=200,
326
- req_body_size=len(req_body),
327
- resp_body_size=len(resp_body),
328
- elapsed_ms=0,
329
- content_type=resp_headers.get("content-type", ""),
330
- is_streaming=is_streaming,
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
- continue
334
- # Reroute returned but failed fall through to forward
335
- # Inject MTRX memory context into request before forwarding
336
- injected_body = await try_inject_context(
337
- req_body=req_body,
338
- req_headers=req_headers,
339
- matrx_base_url=self.matrx_base_url,
340
- matrx_key=self.matrx_key,
341
- session_id=_req_session_id,
342
- )
343
- body_to_forward = injected_body if injected_body is not None else req_body
344
- fwd_headers = dict(req_headers)
345
- fwd_headers["content-length"] = str(len(body_to_forward))
346
- up_writer.write(req_line)
347
- await self._write_headers(up_writer, fwd_headers)
348
- up_writer.write(body_to_forward)
349
- await up_writer.drain()
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
- asyncio.create_task(
406
- self._ship_telemetry(
407
- hostname=hostname,
408
- method=method,
409
- path=path,
410
- status_code=status_code,
411
- req_body_size=req_body_size,
412
- resp_body_size=resp_body_size,
413
- elapsed_ms=elapsed_ms,
414
- content_type=content_type,
415
- is_streaming=is_streaming,
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
- return 0, b""
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.cursor_connect import parse_all_frames
504
- from matrx.cli.cursor_extraction import (
505
- extract_from_response_frame,
506
- parse_response_proto,
507
- ship_ai_telemetry,
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
- for flags, payload in frames:
518
- if flags == 0x02: # end-of-stream trailer — stop
519
- break
520
- resp_proto = parse_response_proto(payload)
521
- frame_data = extract_from_response_frame(resp_proto)
522
- if frame_data:
523
- accumulated["response_text"] = (
524
- accumulated.get("response_text", "") + frame_data.get("text", "")
525
- )
526
- accumulated["tool_calls"].extend(frame_data.get("tool_calls", []))
527
- if frame_data.get("usage"):
528
- accumulated["usage"] = frame_data["usage"]
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
- content_length = int(v_stripped)
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
- content_length = int(v_stripped)
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()