mtrx-cli 0.1.26 → 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 +73 -5
- package/src/matrx/cli/cursor_config.py +5 -1
- package/src/matrx/cli/cursor_daemon.py +4 -0
- package/src/matrx/cli/cursor_proxy.py +198 -20
- package/src/matrx/cli/cursor_reroute.py +95 -46
- package/src/matrx/cli/launcher.py +43 -1
- package/src/matrx/cli/state.py +10 -0
package/package.json
CHANGED
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.27"
|
|
@@ -302,21 +302,89 @@ def trust_ca_system(cert_path: Path | None = None) -> bool:
|
|
|
302
302
|
"""Attempt to trust the CA certificate in the OS certificate store.
|
|
303
303
|
|
|
304
304
|
Returns True on success. May require elevated privileges.
|
|
305
|
+
Also persists NODE_EXTRA_CA_CERTS as a user environment variable so
|
|
306
|
+
Node.js processes trust the CA regardless of how they are launched.
|
|
305
307
|
"""
|
|
306
308
|
cert_path = cert_path or ca_cert_path()
|
|
307
309
|
system = platform.system()
|
|
310
|
+
ok = False
|
|
308
311
|
try:
|
|
309
312
|
if system == "Darwin":
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
313
|
+
ok = _trust_ca_macos(cert_path)
|
|
314
|
+
elif system == "Linux":
|
|
315
|
+
ok = _trust_ca_linux(cert_path)
|
|
316
|
+
elif system == "Windows":
|
|
317
|
+
ok = _trust_ca_windows(cert_path)
|
|
315
318
|
except Exception as exc:
|
|
316
319
|
logger.warning("CA trust failed: %s", exc)
|
|
320
|
+
if ok:
|
|
321
|
+
persist_node_extra_ca_certs(cert_path)
|
|
322
|
+
return ok
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def persist_node_extra_ca_certs(cert_path: Path | None = None) -> bool:
|
|
326
|
+
"""Persist NODE_EXTRA_CA_CERTS as a permanent user-level environment variable.
|
|
327
|
+
|
|
328
|
+
This ensures all Node.js processes — including Cursor sub-processes not
|
|
329
|
+
launched via the mtrx launcher — trust the MTRX CA certificate.
|
|
330
|
+
|
|
331
|
+
- Windows: ``setx NODE_EXTRA_CA_CERTS <path>`` writes to HKCU\\Environment;
|
|
332
|
+
all new user processes inherit it automatically.
|
|
333
|
+
- macOS: ``launchctl setenv`` for the current session; ``~/.zprofile`` for
|
|
334
|
+
persistence across reboots.
|
|
335
|
+
- Linux: ``~/.profile`` export line for login shells and display managers.
|
|
336
|
+
|
|
337
|
+
Returns True if the variable was persisted successfully.
|
|
338
|
+
"""
|
|
339
|
+
cert_path = cert_path or ca_cert_path()
|
|
340
|
+
value = str(cert_path)
|
|
341
|
+
system = platform.system()
|
|
342
|
+
try:
|
|
343
|
+
if system == "Windows":
|
|
344
|
+
result = subprocess.run(
|
|
345
|
+
["setx", "NODE_EXTRA_CA_CERTS", value],
|
|
346
|
+
capture_output=True,
|
|
347
|
+
timeout=15,
|
|
348
|
+
)
|
|
349
|
+
if result.returncode == 0:
|
|
350
|
+
logger.info("persist_node_extra_ca_certs: set via setx")
|
|
351
|
+
return True
|
|
352
|
+
logger.warning(
|
|
353
|
+
"persist_node_extra_ca_certs: setx failed: %s",
|
|
354
|
+
result.stderr.decode("utf-8", errors="replace").strip(),
|
|
355
|
+
)
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
if system == "Darwin":
|
|
359
|
+
subprocess.run(
|
|
360
|
+
["launchctl", "setenv", "NODE_EXTRA_CA_CERTS", value],
|
|
361
|
+
capture_output=True,
|
|
362
|
+
timeout=10,
|
|
363
|
+
)
|
|
364
|
+
zprofile = Path.home() / ".zprofile"
|
|
365
|
+
_append_env_export(zprofile, "NODE_EXTRA_CA_CERTS", value)
|
|
366
|
+
logger.info("persist_node_extra_ca_certs: set via launchctl + ~/.zprofile")
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
if system == "Linux":
|
|
370
|
+
profile = Path.home() / ".profile"
|
|
371
|
+
_append_env_export(profile, "NODE_EXTRA_CA_CERTS", value)
|
|
372
|
+
logger.info("persist_node_extra_ca_certs: set via ~/.profile")
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
logger.warning("persist_node_extra_ca_certs failed: %s", exc)
|
|
317
377
|
return False
|
|
318
378
|
|
|
319
379
|
|
|
380
|
+
def _append_env_export(path: Path, name: str, value: str) -> None:
|
|
381
|
+
"""Append ``export NAME="value"`` to a shell profile file if not already present."""
|
|
382
|
+
line = f'\nexport {name}="{value}" # added by mtrx\n'
|
|
383
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
384
|
+
if name not in text:
|
|
385
|
+
path.write_text(text + line, encoding="utf-8")
|
|
386
|
+
|
|
387
|
+
|
|
320
388
|
def _trust_ca_macos(cert_path: Path) -> bool:
|
|
321
389
|
result = subprocess.run(
|
|
322
390
|
[
|
|
@@ -464,6 +464,8 @@ def configure_cursor_proxy_settings(
|
|
|
464
464
|
previous = {
|
|
465
465
|
"http.proxy": settings.get("http.proxy"),
|
|
466
466
|
"http.proxyStrictSSL": settings.get("http.proxyStrictSSL"),
|
|
467
|
+
"http.proxySupport": settings.get("http.proxySupport"),
|
|
468
|
+
"http.systemCertificates": settings.get("http.systemCertificates"),
|
|
467
469
|
"terminal.integrated.env.osx": settings.get("terminal.integrated.env.osx"),
|
|
468
470
|
"terminal.integrated.env.linux": settings.get("terminal.integrated.env.linux"),
|
|
469
471
|
"terminal.integrated.env.windows": settings.get("terminal.integrated.env.windows"),
|
|
@@ -471,6 +473,8 @@ def configure_cursor_proxy_settings(
|
|
|
471
473
|
|
|
472
474
|
settings["http.proxy"] = proxy_url
|
|
473
475
|
settings["http.proxyStrictSSL"] = False
|
|
476
|
+
settings["http.proxySupport"] = "override" # force all extensions through proxy agent
|
|
477
|
+
settings["http.systemCertificates"] = True # use OS store where our CA is already trusted
|
|
474
478
|
|
|
475
479
|
# Inject NODE_EXTRA_CA_CERTS into integrated terminal env so Cursor's
|
|
476
480
|
# Node.js runtime trusts our CA. Cursor itself reads this from the
|
|
@@ -501,7 +505,7 @@ def restore_cursor_proxy_settings(previous: dict) -> bool:
|
|
|
501
505
|
"""Restore Cursor's settings.json to pre-proxy values."""
|
|
502
506
|
settings = _read_settings_json()
|
|
503
507
|
|
|
504
|
-
for key in ("http.proxy", "http.proxyStrictSSL"):
|
|
508
|
+
for key in ("http.proxy", "http.proxyStrictSSL", "http.proxySupport", "http.systemCertificates"):
|
|
505
509
|
old = previous.get(key)
|
|
506
510
|
if old is None:
|
|
507
511
|
settings.pop(key, None)
|
|
@@ -41,6 +41,8 @@ def main() -> int:
|
|
|
41
41
|
matrx_base_url = config.get("matrx_base_url", "")
|
|
42
42
|
host = config.get("host", "127.0.0.1")
|
|
43
43
|
port = config.get("port", 8842)
|
|
44
|
+
agent_id = config.get("agent_id") or None
|
|
45
|
+
group_id = config.get("group_id") or None
|
|
44
46
|
|
|
45
47
|
if not matrx_key or not matrx_base_url:
|
|
46
48
|
print("Invalid proxy config: matrx_key and matrx_base_url required", file=sys.stderr)
|
|
@@ -56,6 +58,8 @@ def main() -> int:
|
|
|
56
58
|
host=host,
|
|
57
59
|
port=port,
|
|
58
60
|
pid_file=pid_file,
|
|
61
|
+
agent_id=agent_id,
|
|
62
|
+
group_id=group_id,
|
|
59
63
|
)
|
|
60
64
|
return 0
|
|
61
65
|
|
|
@@ -19,14 +19,16 @@ 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
|
|
|
@@ -55,19 +57,61 @@ except ImportError:
|
|
|
55
57
|
|
|
56
58
|
logger = logging.getLogger(__name__)
|
|
57
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
|
+
|
|
58
90
|
_MAX_BODY_BYTES = 50 * 1024 * 1024 # 50 MB hard limit for buffered request bodies
|
|
59
91
|
|
|
60
92
|
DEFAULT_PORT = 8842
|
|
61
93
|
PROXY_HOST = "127.0.0.1"
|
|
62
94
|
HEALTH_PATH = "/__mtrx_health__"
|
|
63
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
|
+
|
|
64
107
|
# Domains whose TLS we intercept for observability.
|
|
65
108
|
_INTERCEPT_DOMAINS = {
|
|
66
109
|
"api2.cursor.sh",
|
|
67
110
|
"api3.cursor.sh",
|
|
68
111
|
"api4.cursor.sh",
|
|
69
112
|
"api5.cursor.sh",
|
|
70
|
-
"
|
|
113
|
+
"agent.api5.cursor.sh", # Cloud Agent (privacy mode)
|
|
114
|
+
"agentn.api5.cursor.sh", # Cloud Agent (non-privacy mode)
|
|
71
115
|
"api.anthropic.com",
|
|
72
116
|
"api.openai.com",
|
|
73
117
|
}
|
|
@@ -77,6 +121,8 @@ _PREWARM_DOMAINS = (
|
|
|
77
121
|
"api3.cursor.sh",
|
|
78
122
|
"api4.cursor.sh",
|
|
79
123
|
"api5.cursor.sh",
|
|
124
|
+
"agent.api5.cursor.sh",
|
|
125
|
+
"agentn.api5.cursor.sh",
|
|
80
126
|
)
|
|
81
127
|
|
|
82
128
|
|
|
@@ -90,16 +136,21 @@ class MITMProxy:
|
|
|
90
136
|
matrx_base_url: str,
|
|
91
137
|
host: str = PROXY_HOST,
|
|
92
138
|
port: int = DEFAULT_PORT,
|
|
139
|
+
agent_id: str | None = None,
|
|
140
|
+
group_id: str | None = None,
|
|
93
141
|
):
|
|
94
142
|
self.matrx_key = matrx_key
|
|
95
143
|
self.matrx_base_url = matrx_base_url.rstrip("/")
|
|
96
144
|
self.host = host
|
|
97
145
|
self.port = port
|
|
146
|
+
self._agent_id: str | None = agent_id
|
|
147
|
+
self._group_id: str | None = group_id
|
|
98
148
|
self._server: asyncio.Server | None = None
|
|
99
149
|
self._telemetry_client: httpx.AsyncClient | None = None
|
|
100
150
|
self._cert_cache: CertCache | None = None
|
|
101
151
|
self._request_count = 0
|
|
102
152
|
self._connect_count = 0
|
|
153
|
+
self._inbox_poll_task: asyncio.Task | None = None
|
|
103
154
|
|
|
104
155
|
async def start(self) -> None:
|
|
105
156
|
ca_key, ca_cert = load_ca()
|
|
@@ -110,6 +161,16 @@ class MITMProxy:
|
|
|
110
161
|
self._handle_client, self.host, self.port
|
|
111
162
|
)
|
|
112
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
|
+
)
|
|
113
174
|
|
|
114
175
|
async def serve_forever(self) -> None:
|
|
115
176
|
if self._server is None:
|
|
@@ -119,6 +180,10 @@ class MITMProxy:
|
|
|
119
180
|
await self._server.serve_forever()
|
|
120
181
|
|
|
121
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
|
|
122
187
|
if self._server:
|
|
123
188
|
self._server.close()
|
|
124
189
|
await self._server.wait_closed()
|
|
@@ -129,6 +194,58 @@ class MITMProxy:
|
|
|
129
194
|
def request_count(self) -> int:
|
|
130
195
|
return self._request_count
|
|
131
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
|
+
|
|
132
249
|
# -----------------------------------------------------------------
|
|
133
250
|
# Connection handling
|
|
134
251
|
# -----------------------------------------------------------------
|
|
@@ -397,9 +514,18 @@ class MITMProxy:
|
|
|
397
514
|
success, resp_headers, resp_body, is_streaming = result
|
|
398
515
|
if success and resp_body is not None:
|
|
399
516
|
self._request_count += 1
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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)
|
|
403
529
|
asyncio.create_task(
|
|
404
530
|
self._ship_telemetry(
|
|
405
531
|
hostname=hostname,
|
|
@@ -407,7 +533,7 @@ class MITMProxy:
|
|
|
407
533
|
path=path,
|
|
408
534
|
status_code=200,
|
|
409
535
|
req_body_size=len(req_body),
|
|
410
|
-
resp_body_size=
|
|
536
|
+
resp_body_size=resp_body_size,
|
|
411
537
|
elapsed_ms=0,
|
|
412
538
|
content_type=resp_headers.get("content-type", ""),
|
|
413
539
|
is_streaming=is_streaming,
|
|
@@ -425,6 +551,7 @@ class MITMProxy:
|
|
|
425
551
|
)
|
|
426
552
|
body_to_forward = injected_body if injected_body is not None else req_body
|
|
427
553
|
fwd_headers = dict(req_headers)
|
|
554
|
+
fwd_headers.pop("transfer-encoding", None) # remove chunked before setting content-length
|
|
428
555
|
fwd_headers["content-length"] = str(len(body_to_forward))
|
|
429
556
|
up_writer.write(req_line)
|
|
430
557
|
self._write_headers(up_writer, fwd_headers)
|
|
@@ -494,19 +621,20 @@ class MITMProxy:
|
|
|
494
621
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
495
622
|
self._request_count += 1
|
|
496
623
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
+
)
|
|
508
637
|
)
|
|
509
|
-
)
|
|
510
638
|
|
|
511
639
|
conn_h = resp_headers.get("connection", "").lower()
|
|
512
640
|
if "close" in conn_h:
|
|
@@ -842,6 +970,38 @@ class MITMProxy:
|
|
|
842
970
|
writer.write(body)
|
|
843
971
|
# Note: drain is caller's responsibility
|
|
844
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
|
+
|
|
845
1005
|
# -----------------------------------------------------------------
|
|
846
1006
|
# Raw bidirectional pipe (for opaque tunnels)
|
|
847
1007
|
# -----------------------------------------------------------------
|
|
@@ -888,7 +1048,7 @@ class MITMProxy:
|
|
|
888
1048
|
content_type: str,
|
|
889
1049
|
is_streaming: bool,
|
|
890
1050
|
) -> None:
|
|
891
|
-
if self._telemetry_client is None:
|
|
1051
|
+
if self._telemetry_client is None or not self.matrx_key:
|
|
892
1052
|
return
|
|
893
1053
|
|
|
894
1054
|
payload = {
|
|
@@ -905,11 +1065,19 @@ class MITMProxy:
|
|
|
905
1065
|
}
|
|
906
1066
|
url = f"{self.matrx_base_url}/v1/telemetry/cursor"
|
|
907
1067
|
try:
|
|
908
|
-
await self._telemetry_client.post(
|
|
1068
|
+
resp = await self._telemetry_client.post(
|
|
909
1069
|
url,
|
|
910
1070
|
json=payload,
|
|
911
1071
|
headers={"X-Matrx-Key": self.matrx_key},
|
|
912
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
|
+
)
|
|
913
1081
|
except Exception:
|
|
914
1082
|
logger.debug("telemetry ship failed", exc_info=True)
|
|
915
1083
|
|
|
@@ -925,8 +1093,16 @@ def run_proxy(
|
|
|
925
1093
|
host: str = PROXY_HOST,
|
|
926
1094
|
port: int = DEFAULT_PORT,
|
|
927
1095
|
pid_file: Path | None = None,
|
|
1096
|
+
agent_id: str | None = None,
|
|
1097
|
+
group_id: str | None = None,
|
|
928
1098
|
) -> None:
|
|
929
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
|
+
|
|
930
1106
|
if pid_file:
|
|
931
1107
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
932
1108
|
pid_file.write_text(str(os.getpid()), encoding="utf-8")
|
|
@@ -936,6 +1112,8 @@ def run_proxy(
|
|
|
936
1112
|
matrx_base_url=matrx_base_url,
|
|
937
1113
|
host=host,
|
|
938
1114
|
port=port,
|
|
1115
|
+
agent_id=agent_id,
|
|
1116
|
+
group_id=group_id,
|
|
939
1117
|
)
|
|
940
1118
|
|
|
941
1119
|
loop = asyncio.new_event_loop()
|
|
@@ -17,7 +17,7 @@ import asyncio
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
19
|
import re
|
|
20
|
-
from typing import Any
|
|
20
|
+
from typing import Any, AsyncGenerator
|
|
21
21
|
|
|
22
22
|
import httpx
|
|
23
23
|
|
|
@@ -27,7 +27,9 @@ logger = logging.getLogger(__name__)
|
|
|
27
27
|
# Cursor uses aiserver.v1.AiServerService for all AI endpoints.
|
|
28
28
|
_AI_PATH_PATTERNS = (
|
|
29
29
|
r"AiServerService",
|
|
30
|
-
r"AiService",
|
|
30
|
+
# r"AiService" intentionally omitted — too broad, matches non-inference endpoints like
|
|
31
|
+
# KnowledgeBaseList, UpdateVscodeProfile, GetDefaultModel. Actual inference methods
|
|
32
|
+
# on AiService are all covered by their specific method-level patterns below.
|
|
31
33
|
r"ChatService",
|
|
32
34
|
r"StreamUnifiedChat",
|
|
33
35
|
r"StreamDiff",
|
|
@@ -51,7 +53,7 @@ _REROUTABLE_AI_PATH_PATTERNS = (
|
|
|
51
53
|
)
|
|
52
54
|
_AI_SERVICE_CANDIDATE_PATTERNS = (
|
|
53
55
|
r"AiServerService",
|
|
54
|
-
r"AiService"
|
|
56
|
+
# r"AiService" intentionally omitted — see note in _AI_PATH_PATTERNS above.
|
|
55
57
|
r"AgentService",
|
|
56
58
|
r"ChatService",
|
|
57
59
|
r"CppService",
|
|
@@ -289,6 +291,85 @@ def _inject_memory_context_items(
|
|
|
289
291
|
return len(injected_items)
|
|
290
292
|
|
|
291
293
|
|
|
294
|
+
# Pre-built Connect end-of-stream frame: flags=0x02, payload=b"{}"
|
|
295
|
+
# Frame format: [flags:1][length:4 BE][payload] → \x02 \x00\x00\x00\x02 {}
|
|
296
|
+
_EOS_FRAME = b"\x02\x00\x00\x00\x02{}"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _stream_rerouted_frames(
|
|
300
|
+
url: str,
|
|
301
|
+
payload: dict[str, Any],
|
|
302
|
+
headers: dict[str, str],
|
|
303
|
+
provider: str,
|
|
304
|
+
) -> AsyncGenerator[bytes, None]:
|
|
305
|
+
"""POST to MTRX and yield Connect-framed protobuf text deltas as SSE events arrive.
|
|
306
|
+
|
|
307
|
+
Parses each ``data: `` line from the SSE stream, extracts the text delta
|
|
308
|
+
(Anthropic ``content_block_delta`` or OpenAI ``choices[].delta.content``),
|
|
309
|
+
wraps it in a Connect data frame (flags=0x00), and yields it immediately so
|
|
310
|
+
Cursor sees tokens arrive incrementally.
|
|
311
|
+
|
|
312
|
+
Always terminates with a Connect end-of-stream frame (flags=0x02). Any error
|
|
313
|
+
causes a silent fallback: the generator yields only the EOS frame so Cursor
|
|
314
|
+
sees an empty stream rather than a broken connection.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
from matrx.cli.cursor_connect import build_connect_frame
|
|
318
|
+
from matrx.cli.cursor_proto import _PROTOS_AVAILABLE, server_chat_pb2 # type: ignore[import]
|
|
319
|
+
except Exception:
|
|
320
|
+
yield _EOS_FRAME
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
if not _PROTOS_AVAILABLE:
|
|
324
|
+
yield _EOS_FRAME
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
async with httpx.AsyncClient(
|
|
329
|
+
timeout=httpx.Timeout(timeout=90.0, connect=5.0)
|
|
330
|
+
) as client:
|
|
331
|
+
async with client.stream("POST", url, json=payload, headers=headers) as resp:
|
|
332
|
+
if resp.status_code >= 400:
|
|
333
|
+
logger.info(
|
|
334
|
+
"cursor_reroute: stream upstream returned %s", resp.status_code
|
|
335
|
+
)
|
|
336
|
+
yield _EOS_FRAME
|
|
337
|
+
return
|
|
338
|
+
async for raw_line in resp.aiter_lines():
|
|
339
|
+
if not raw_line.startswith("data: "):
|
|
340
|
+
continue
|
|
341
|
+
data_str = raw_line[6:].strip()
|
|
342
|
+
if data_str == "[DONE]":
|
|
343
|
+
break
|
|
344
|
+
try:
|
|
345
|
+
chunk = json.loads(data_str)
|
|
346
|
+
except json.JSONDecodeError:
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
text = ""
|
|
350
|
+
if provider == "anthropic":
|
|
351
|
+
# Mirrors extract_from_anthropic_sse_response inner loop
|
|
352
|
+
if chunk.get("type") == "content_block_delta":
|
|
353
|
+
delta = chunk.get("delta") or {}
|
|
354
|
+
if delta.get("type") == "text_delta":
|
|
355
|
+
text = delta.get("text") or ""
|
|
356
|
+
else:
|
|
357
|
+
# Mirrors extract_from_openai_sse_response inner loop
|
|
358
|
+
for choice in chunk.get("choices") or []:
|
|
359
|
+
delta = choice.get("delta") or {}
|
|
360
|
+
text += delta.get("content") or ""
|
|
361
|
+
|
|
362
|
+
if text:
|
|
363
|
+
resp_msg = server_chat_pb2.StreamUnifiedChatWithToolsResponse()
|
|
364
|
+
resp_msg.content.text = text
|
|
365
|
+
yield build_connect_frame(0x00, resp_msg.SerializeToString())
|
|
366
|
+
|
|
367
|
+
except Exception:
|
|
368
|
+
logger.warning("cursor_reroute: streaming reroute error", exc_info=True)
|
|
369
|
+
|
|
370
|
+
yield _EOS_FRAME
|
|
371
|
+
|
|
372
|
+
|
|
292
373
|
async def try_reroute_to_matrx(
|
|
293
374
|
*,
|
|
294
375
|
path: str,
|
|
@@ -300,13 +381,15 @@ async def try_reroute_to_matrx(
|
|
|
300
381
|
session_id: str | None = None,
|
|
301
382
|
group_id: str | None = None,
|
|
302
383
|
project_id: str | None = None,
|
|
303
|
-
) -> tuple[bool, dict[str, str], bytes | None, bool] | None:
|
|
384
|
+
) -> tuple[bool, dict[str, str], AsyncGenerator[bytes, None] | bytes | None, bool] | None:
|
|
304
385
|
"""
|
|
305
386
|
Attempt to reroute a Cursor AI request through MTRX.
|
|
306
387
|
|
|
307
388
|
Returns:
|
|
308
|
-
(success, response_headers,
|
|
389
|
+
(success, response_headers, response_body_or_generator, is_streaming) if handled,
|
|
309
390
|
None to fall back to normal forward.
|
|
391
|
+
response_body_or_generator is an AsyncGenerator[bytes, None] of Connect frames;
|
|
392
|
+
the proxy must iterate it using chunked transfer encoding.
|
|
310
393
|
"""
|
|
311
394
|
classification = classify_ai_request(method, path, req_headers)
|
|
312
395
|
if not classification["candidate"]:
|
|
@@ -319,8 +402,6 @@ async def try_reroute_to_matrx(
|
|
|
319
402
|
from matrx.cli.cursor_connect import is_connect_proto_request, parse_connect_frame
|
|
320
403
|
from matrx.cli.cursor_extraction import (
|
|
321
404
|
_PROTOS_AVAILABLE,
|
|
322
|
-
extract_from_anthropic_sse_response,
|
|
323
|
-
extract_from_openai_sse_response,
|
|
324
405
|
extract_from_request,
|
|
325
406
|
parse_request_proto,
|
|
326
407
|
ship_ai_telemetry,
|
|
@@ -372,52 +453,20 @@ async def try_reroute_to_matrx(
|
|
|
372
453
|
headers["Authorization"] = f"Bearer {matrx_key}"
|
|
373
454
|
|
|
374
455
|
url = f"{matrx_base_url.rstrip('/')}{upstream_path}"
|
|
375
|
-
try:
|
|
376
|
-
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout=90.0, connect=5.0)) as client:
|
|
377
|
-
resp = await client.post(url, json={**payload, "stream": True}, headers=headers)
|
|
378
|
-
except Exception:
|
|
379
|
-
logger.warning("cursor_reroute: upstream request failed for %s", path, exc_info=True)
|
|
380
|
-
return None
|
|
381
|
-
|
|
382
|
-
if resp.status_code >= 400:
|
|
383
|
-
logger.info(
|
|
384
|
-
"cursor_reroute: upstream returned %s for %s; forwarding unchanged",
|
|
385
|
-
resp.status_code,
|
|
386
|
-
path,
|
|
387
|
-
)
|
|
388
|
-
return None
|
|
389
|
-
|
|
390
456
|
provider = _detect_provider_from_model(str(payload.get("model", "")))
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
usage = frame_data.get("usage")
|
|
398
|
-
response_body = _build_cursor_response_bytes(text=text, usage=usage)
|
|
399
|
-
if response_body is None:
|
|
400
|
-
return None
|
|
401
|
-
|
|
402
|
-
response_telemetry = {
|
|
403
|
-
"session_id": extracted.get("session_id") or session_id or "",
|
|
404
|
-
"conversation_id": extracted.get("conversation_id") or "",
|
|
405
|
-
"model": extracted.get("model") or "",
|
|
406
|
-
"files": extracted.get("files", []),
|
|
407
|
-
"edits": extracted.get("edits", []),
|
|
408
|
-
"response_text": text,
|
|
409
|
-
"tool_calls": [],
|
|
410
|
-
"usage": usage,
|
|
411
|
-
}
|
|
412
|
-
asyncio.create_task(ship_ai_telemetry(response_telemetry, matrx_base_url, matrx_key))
|
|
413
|
-
|
|
457
|
+
gen = _stream_rerouted_frames(
|
|
458
|
+
url=url,
|
|
459
|
+
payload={**payload, "stream": True},
|
|
460
|
+
headers=headers,
|
|
461
|
+
provider=provider,
|
|
462
|
+
)
|
|
414
463
|
return (
|
|
415
464
|
True,
|
|
416
465
|
{
|
|
417
466
|
"content-type": req_headers.get("content-type", "application/connect+proto"),
|
|
418
467
|
"connect-protocol-version": "1",
|
|
419
468
|
},
|
|
420
|
-
|
|
469
|
+
gen,
|
|
421
470
|
True,
|
|
422
471
|
)
|
|
423
472
|
|
|
@@ -582,16 +582,21 @@ def _build_codex_env(
|
|
|
582
582
|
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
583
583
|
session_id = str(uuid.uuid4())
|
|
584
584
|
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
585
|
+
codex_root = ensure_root_url(matrx.get("base_url"))
|
|
586
|
+
if not group_id:
|
|
587
|
+
group_id = _auto_resolve_default_group_id(codex_root, mx_key)
|
|
585
588
|
runtime_agent_id = (
|
|
586
589
|
(orchestration or {}).get("agent_id")
|
|
587
590
|
or _runtime_agent_basename("codex")[0]
|
|
588
591
|
)
|
|
592
|
+
workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
|
|
589
593
|
header_parts = [
|
|
590
594
|
f'"Authorization" = "Bearer {provider_bearer}"',
|
|
591
595
|
f'"X-Matrx-Key" = "{mx_key}"',
|
|
592
596
|
f'"X-Matrx-Agent-Id" = "{runtime_agent_id}"',
|
|
593
597
|
'"X-Matrx-Provider" = "codex"',
|
|
594
598
|
f'"X-Matrx-Session-Id" = "{session_id}"',
|
|
599
|
+
f'"X-Matrx-Workspace" = "{workspace_fp}"',
|
|
595
600
|
]
|
|
596
601
|
if group_id:
|
|
597
602
|
header_parts.append(f'"X-Matrx-Group" = "{group_id}"')
|
|
@@ -655,11 +660,14 @@ def _build_gemini_env(
|
|
|
655
660
|
env.pop(key, None)
|
|
656
661
|
env.pop("MTRX_KEY", None)
|
|
657
662
|
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
663
|
+
if not group_id:
|
|
664
|
+
group_id = _auto_resolve_default_group_id(proxy_root, mx_key)
|
|
658
665
|
session_id = str(uuid.uuid4())
|
|
659
666
|
runtime_agent_id = (
|
|
660
667
|
(orchestration or {}).get("agent_id")
|
|
661
668
|
or _runtime_agent_basename("gemini")[0]
|
|
662
669
|
)
|
|
670
|
+
workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
|
|
663
671
|
ctx_params: list[str] = []
|
|
664
672
|
if project_id:
|
|
665
673
|
ctx_params.append(f"mtrx_project={project_id}")
|
|
@@ -669,6 +677,7 @@ def _build_gemini_env(
|
|
|
669
677
|
ctx_params.append(f"mtrx_session={session_id}")
|
|
670
678
|
if runtime_agent_id:
|
|
671
679
|
ctx_params.append(f"mtrx_agent={runtime_agent_id}")
|
|
680
|
+
ctx_params.append(f"mtrx_workspace={workspace_fp}")
|
|
672
681
|
git_branch, git_commit = _capture_git_context(_workspace_cwd(env))
|
|
673
682
|
git_repo_url = _capture_git_remote_url(_workspace_cwd(env))
|
|
674
683
|
if git_branch:
|
|
@@ -686,6 +695,7 @@ def _build_gemini_env(
|
|
|
686
695
|
f"x-matrx-agent-id: {runtime_agent_id}",
|
|
687
696
|
"x-matrx-provider: gemini_code",
|
|
688
697
|
f"x-matrx-session-id: {session_id}",
|
|
698
|
+
f"x-matrx-workspace: {workspace_fp}",
|
|
689
699
|
]
|
|
690
700
|
if group_id:
|
|
691
701
|
custom_headers.append(f"x-matrx-group: {group_id}")
|
|
@@ -767,6 +777,35 @@ def _build_gemini_env(
|
|
|
767
777
|
return env, "missing_auth"
|
|
768
778
|
|
|
769
779
|
|
|
780
|
+
def _compute_workspace_fingerprint(cwd: str) -> str:
|
|
781
|
+
return hashlib.sha256(cwd.encode("utf-8")).hexdigest()[:16]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _auto_resolve_default_group_id(base_url: str, mx_key: str) -> str:
|
|
785
|
+
"""Fetch the user's groups; return the sole/default group ID if unambiguous."""
|
|
786
|
+
if not base_url or not mx_key:
|
|
787
|
+
return ""
|
|
788
|
+
try:
|
|
789
|
+
with httpx.Client(timeout=5) as client:
|
|
790
|
+
resp = client.get(
|
|
791
|
+
f"{base_url.rstrip('/')}/v1/groups",
|
|
792
|
+
headers={"X-Matrx-Key": mx_key},
|
|
793
|
+
)
|
|
794
|
+
if resp.status_code != 200:
|
|
795
|
+
return ""
|
|
796
|
+
groups = resp.json().get("groups", [])
|
|
797
|
+
if not groups:
|
|
798
|
+
return ""
|
|
799
|
+
if len(groups) == 1:
|
|
800
|
+
return str(groups[0].get("id", ""))
|
|
801
|
+
for g in groups:
|
|
802
|
+
if g.get("is_default"):
|
|
803
|
+
return str(g.get("id", ""))
|
|
804
|
+
return ""
|
|
805
|
+
except (httpx.HTTPError, Exception):
|
|
806
|
+
return ""
|
|
807
|
+
|
|
808
|
+
|
|
770
809
|
def _build_claude_env(
|
|
771
810
|
state: dict,
|
|
772
811
|
route: str,
|
|
@@ -793,12 +832,14 @@ def _build_claude_env(
|
|
|
793
832
|
env["ANTHROPIC_BASE_URL"] = proxy_root
|
|
794
833
|
env.pop("ANTHROPIC_API_KEY", None)
|
|
795
834
|
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
835
|
+
if not group_id:
|
|
836
|
+
group_id = _auto_resolve_default_group_id(proxy_root, mx_key)
|
|
796
837
|
session_id = str(uuid.uuid4())
|
|
797
838
|
runtime_agent_id = (
|
|
798
839
|
(orchestration or {}).get("agent_id")
|
|
799
840
|
or _runtime_agent_basename("claude")[0]
|
|
800
841
|
)
|
|
801
|
-
|
|
842
|
+
workspace_fp = _compute_workspace_fingerprint(_workspace_cwd(env))
|
|
802
843
|
env_snap = _capture_env_snapshot()
|
|
803
844
|
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
804
845
|
custom_headers = "\n".join(
|
|
@@ -809,6 +850,7 @@ def _build_claude_env(
|
|
|
809
850
|
f"x-matrx-session-id: {session_id}",
|
|
810
851
|
]
|
|
811
852
|
)
|
|
853
|
+
custom_headers += f"\nx-matrx-workspace: {workspace_fp}"
|
|
812
854
|
if group_id:
|
|
813
855
|
custom_headers += f"\nx-matrx-group: {group_id}"
|
|
814
856
|
if project_id:
|
package/src/matrx/cli/state.py
CHANGED
|
@@ -173,6 +173,10 @@ def normalize_matrx_key(value: str | None) -> str:
|
|
|
173
173
|
return cleaned
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
def _normalize_binding_value(value: str | None) -> str:
|
|
177
|
+
return (value or "").strip()
|
|
178
|
+
|
|
179
|
+
|
|
176
180
|
def ensure_v1_url(base_url: str | None) -> str:
|
|
177
181
|
cleaned = _normalize_base_url(base_url).rstrip("/")
|
|
178
182
|
if cleaned.endswith("/v1"):
|
|
@@ -320,6 +324,12 @@ def _normalize_state(state: dict) -> None:
|
|
|
320
324
|
binding["matrx_key"] = matrx_key
|
|
321
325
|
else:
|
|
322
326
|
binding.pop("matrx_key", None)
|
|
327
|
+
for field in ("project_id", "group_id"):
|
|
328
|
+
cleaned = _normalize_binding_value(binding.get(field))
|
|
329
|
+
if cleaned:
|
|
330
|
+
binding[field] = cleaned
|
|
331
|
+
else:
|
|
332
|
+
binding.pop(field, None)
|
|
323
333
|
|
|
324
334
|
|
|
325
335
|
def _normalize_base_url(base_url: str | None) -> str:
|