mtrx-cli 0.1.25 → 0.1.26

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrx-cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "MATRX CLI for routing Codex, Claude, and Cursor through Matrx",
5
5
  "homepage": "https://mtrx.so",
6
6
  "repository": {
@@ -1 +1 @@
1
- __version__ = "0.1.25"
1
+ __version__ = "0.1.26"
@@ -12,14 +12,16 @@ import datetime
12
12
  import logging
13
13
  import os
14
14
  import platform
15
+ import ssl
15
16
  import subprocess
16
17
  import threading
18
+ from dataclasses import dataclass
17
19
  from pathlib import Path
18
20
 
19
21
  from cryptography import x509
20
22
  from cryptography.hazmat.primitives import hashes, serialization
21
23
  from cryptography.hazmat.primitives.asymmetric import rsa
22
- from cryptography.x509.oid import NameOID
24
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
23
25
 
24
26
  from matrx.cli.state import config_dir
25
27
 
@@ -30,6 +32,24 @@ _CA_KEY_FILE = "mtrx-ca.key"
30
32
  _CA_CERT_FILE = "mtrx-ca.pem"
31
33
  _CERT_VALIDITY_YEARS = 5
32
34
  _LEAF_VALIDITY_DAYS = 365
35
+ _CA_COMMON_NAME = "MTRX Cursor Proxy CA"
36
+ _CA_ORGANIZATION = "MTRX"
37
+ _WINDOWS_ROOT_USER = "CurrentUser\\Root"
38
+ _WINDOWS_ROOT_MACHINE = "LocalMachine\\Root"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class HostCertMaterial:
43
+ hostname: str
44
+ cert_pem: bytes
45
+ key_pem: bytes
46
+ cert_path: Path
47
+ key_path: Path
48
+ leaf_serial: str
49
+ leaf_sha256: str
50
+ ca_sha256: str
51
+ chain_length: int
52
+ ssl_context: ssl.SSLContext
33
53
 
34
54
 
35
55
  def ca_dir() -> Path:
@@ -63,8 +83,8 @@ def generate_ca(*, force: bool = False) -> tuple[Path, Path]:
63
83
 
64
84
  key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
65
85
  subject = issuer = x509.Name([
66
- x509.NameAttribute(NameOID.ORGANIZATION_NAME, "MTRX"),
67
- x509.NameAttribute(NameOID.COMMON_NAME, "MTRX Cursor Proxy CA"),
86
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, _CA_ORGANIZATION),
87
+ x509.NameAttribute(NameOID.COMMON_NAME, _CA_COMMON_NAME),
68
88
  ])
69
89
  now = datetime.datetime.now(datetime.timezone.utc)
70
90
  cert = (
@@ -75,7 +95,11 @@ def generate_ca(*, force: bool = False) -> tuple[Path, Path]:
75
95
  .serial_number(x509.random_serial_number())
76
96
  .not_valid_before(now)
77
97
  .not_valid_after(now + datetime.timedelta(days=_CERT_VALIDITY_YEARS * 365))
78
- .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
98
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
99
+ .add_extension(
100
+ x509.SubjectKeyIdentifier.from_public_key(key.public_key()),
101
+ critical=False,
102
+ )
79
103
  .add_extension(
80
104
  x509.KeyUsage(
81
105
  digital_signature=True,
@@ -127,48 +151,76 @@ class CertCache:
127
151
  self._ca_key = ca_key
128
152
  self._ca_cert = ca_cert
129
153
  self._lock = threading.Lock()
130
- self._cache: dict[str, tuple[bytes, bytes]] = {}
131
- self._ctx_cache: dict[str, "ssl.SSLContext"] = {}
154
+ self._cache: dict[str, HostCertMaterial] = {}
132
155
  self._certs_dir = ca_dir() / "certs"
133
156
  self._certs_dir.mkdir(parents=True, exist_ok=True)
134
157
 
135
158
  def get_cert_pair(self, hostname: str) -> tuple[bytes, bytes]:
136
159
  """Return (cert_pem, key_pem) for *hostname*, creating if needed."""
137
- with self._lock:
138
- if hostname in self._cache:
139
- return self._cache[hostname]
140
-
141
- cert_pem, key_pem = self._sign_leaf(hostname)
142
-
143
- with self._lock:
144
- self._cache[hostname] = (cert_pem, key_pem)
145
- return cert_pem, key_pem
160
+ material = self.get_or_create_material(hostname)
161
+ return material.cert_pem, material.key_pem
146
162
 
147
163
  def get_ssl_context(self, hostname: str) -> "ssl.SSLContext":
148
164
  """Return a server-side ``ssl.SSLContext`` for *hostname*."""
149
- import ssl as _ssl
150
-
165
+ return self.get_or_create_material(hostname).ssl_context
166
+
167
+ def get_handshake_info(self, hostname: str) -> dict[str, str | int | bool]:
168
+ material = self.get_or_create_material(hostname)
169
+ return {
170
+ "hostname": hostname,
171
+ "leaf_serial": material.leaf_serial,
172
+ "leaf_sha256": material.leaf_sha256,
173
+ "ca_sha256": material.ca_sha256,
174
+ "chain_length": material.chain_length,
175
+ "cert_path": str(material.cert_path),
176
+ "key_path": str(material.key_path),
177
+ "cache_ready": True,
178
+ }
179
+
180
+ def prewarm(self, hostnames: list[str] | tuple[str, ...]) -> None:
181
+ for hostname in hostnames:
182
+ self.get_or_create_material(hostname)
183
+
184
+ def get_or_create_material(self, hostname: str) -> HostCertMaterial:
151
185
  with self._lock:
152
- if hostname in self._ctx_cache:
153
- return self._ctx_cache[hostname]
186
+ material = self._cache.get(hostname)
187
+ if material is not None:
188
+ return material
154
189
 
155
- cert_pem, key_pem = self.get_cert_pair(hostname)
190
+ material = self._build_material(hostname)
191
+ self._cache[hostname] = material
192
+ return material
156
193
 
194
+ def _build_material(self, hostname: str) -> HostCertMaterial:
195
+ cert_pem, key_pem, leaf_cert = self._sign_leaf(hostname)
157
196
  cert_file = self._certs_dir / f"{hostname}.crt"
158
197
  key_file = self._certs_dir / f"{hostname}.key"
159
198
  cert_file.write_bytes(cert_pem)
160
199
  key_file.write_bytes(key_pem)
161
200
  os.chmod(key_file, 0o600)
162
201
 
163
- ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
202
+ chain = self._pem_certificates(cert_pem)
203
+ if len(chain) < 2:
204
+ raise ValueError(f"incomplete served certificate chain for {hostname}")
205
+
206
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
164
207
  ctx.load_cert_chain(str(cert_file), str(key_file))
165
208
  ctx.set_alpn_protocols(["http/1.1"])
166
209
 
167
- with self._lock:
168
- self._ctx_cache[hostname] = ctx
169
- return ctx
210
+ return HostCertMaterial(
211
+ hostname=hostname,
212
+ cert_pem=cert_pem,
213
+ key_pem=key_pem,
214
+ cert_path=cert_file,
215
+ key_path=key_file,
216
+ leaf_serial=f"{leaf_cert.serial_number:X}",
217
+ leaf_sha256=leaf_cert.fingerprint(hashes.SHA256()).hex(),
218
+ ca_sha256=self._ca_cert.fingerprint(hashes.SHA256()).hex(),
219
+ chain_length=len(chain),
220
+ ssl_context=ctx,
221
+ )
170
222
 
171
- def _sign_leaf(self, hostname: str) -> tuple[bytes, bytes]:
223
+ def _sign_leaf(self, hostname: str) -> tuple[bytes, bytes, x509.Certificate]:
172
224
  leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
173
225
  now = datetime.datetime.now(datetime.timezone.utc)
174
226
  subject = x509.Name([
@@ -182,7 +234,7 @@ class CertCache:
182
234
  .public_key(leaf_key.public_key())
183
235
  .serial_number(x509.random_serial_number())
184
236
  .not_valid_before(now - datetime.timedelta(days=1))
185
- .not_valid_after(now + datetime.timedelta(days=_LEAF_VALIDITY_DAYS))
237
+ .not_valid_after(now + datetime.timedelta(days=min(_LEAF_VALIDITY_DAYS, 397)))
186
238
  .add_extension(
187
239
  x509.SubjectAlternativeName([x509.DNSName(hostname)]),
188
240
  critical=False,
@@ -191,16 +243,55 @@ class CertCache:
191
243
  x509.BasicConstraints(ca=False, path_length=None),
192
244
  critical=True,
193
245
  )
246
+ .add_extension(
247
+ x509.KeyUsage(
248
+ digital_signature=True,
249
+ key_encipherment=True,
250
+ key_cert_sign=False,
251
+ crl_sign=False,
252
+ content_commitment=False,
253
+ data_encipherment=False,
254
+ key_agreement=False,
255
+ encipher_only=False,
256
+ decipher_only=False,
257
+ ),
258
+ critical=True,
259
+ )
260
+ .add_extension(
261
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
262
+ critical=False,
263
+ )
264
+ .add_extension(
265
+ x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()),
266
+ critical=False,
267
+ )
268
+ .add_extension(
269
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(self._ca_key.public_key()),
270
+ critical=False,
271
+ )
194
272
  )
195
273
  leaf_cert = builder.sign(self._ca_key, hashes.SHA256())
196
274
 
197
- cert_pem = leaf_cert.public_bytes(serialization.Encoding.PEM)
275
+ cert_pem = (
276
+ leaf_cert.public_bytes(serialization.Encoding.PEM)
277
+ + self._ca_cert.public_bytes(serialization.Encoding.PEM)
278
+ )
198
279
  key_pem = leaf_key.private_bytes(
199
280
  encoding=serialization.Encoding.PEM,
200
281
  format=serialization.PrivateFormat.TraditionalOpenSSL,
201
282
  encryption_algorithm=serialization.NoEncryption(),
202
283
  )
203
- return cert_pem, key_pem
284
+ return cert_pem, key_pem, leaf_cert
285
+
286
+ @staticmethod
287
+ def _pem_certificates(pem_bytes: bytes) -> list[bytes]:
288
+ marker = b"-----END CERTIFICATE-----"
289
+ certs: list[bytes] = []
290
+ for part in pem_bytes.split(marker):
291
+ if b"-----BEGIN CERTIFICATE-----" not in part:
292
+ continue
293
+ certs.append(part + marker + b"\n")
294
+ return certs
204
295
 
205
296
 
206
297
  # ---------------------------------------------------------------------------
@@ -249,14 +340,71 @@ def _trust_ca_linux(cert_path: Path) -> bool:
249
340
 
250
341
 
251
342
  def _trust_ca_windows(cert_path: Path) -> bool:
252
- result = subprocess.run(
253
- ["certutil", "-addstore", "Root", str(cert_path)],
254
- capture_output=True,
255
- timeout=30,
256
- )
343
+ status = windows_trust_status(cert_path)
344
+ if status["fully_trusted"]:
345
+ return True
346
+
347
+ user_ok = status["user_root"] or _add_ca_to_windows_store(cert_path, user=True)
348
+ machine_ok = status["machine_root"]
349
+ if not machine_ok:
350
+ machine_ok = _add_ca_to_windows_store(cert_path, user=False)
351
+ return user_ok or machine_ok
352
+
353
+
354
+ def _cert_subject_and_fingerprint(cert_path: Path) -> tuple[str, str] | None:
355
+ try:
356
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
357
+ except (OSError, ValueError):
358
+ return None
359
+ return cert.subject.rfc4514_string(), cert.fingerprint(hashes.SHA1()).hex().upper()
360
+
361
+
362
+ def _certutil_store_output(*, user: bool) -> str:
363
+ command = ["certutil"]
364
+ if user:
365
+ command.append("-user")
366
+ command.extend(["-store", "Root"])
367
+ try:
368
+ result = subprocess.run(command, capture_output=True, timeout=15)
369
+ except Exception:
370
+ return ""
371
+ if result.returncode != 0:
372
+ return ""
373
+ return result.stdout.decode("utf-8", errors="replace").upper()
374
+
375
+
376
+ def _is_ca_trusted_windows(cert_path: Path, *, user: bool) -> bool:
377
+ cert_info = _cert_subject_and_fingerprint(cert_path)
378
+ if cert_info is None:
379
+ return False
380
+ subject, fingerprint = cert_info
381
+ output = _certutil_store_output(user=user)
382
+ return fingerprint in output or subject.upper() in output
383
+
384
+
385
+ def _add_ca_to_windows_store(cert_path: Path, *, user: bool) -> bool:
386
+ command = ["certutil"]
387
+ if user:
388
+ command.append("-user")
389
+ command.extend(["-addstore", "Root", str(cert_path)])
390
+ try:
391
+ result = subprocess.run(command, capture_output=True, timeout=30)
392
+ except Exception:
393
+ return False
257
394
  return result.returncode == 0
258
395
 
259
396
 
397
+ def windows_trust_status(cert_path: Path | None = None) -> dict[str, bool]:
398
+ cert_path = cert_path or ca_cert_path()
399
+ user_root = _is_ca_trusted_windows(cert_path, user=True)
400
+ machine_root = _is_ca_trusted_windows(cert_path, user=False)
401
+ return {
402
+ "user_root": user_root,
403
+ "machine_root": machine_root,
404
+ "fully_trusted": user_root and machine_root,
405
+ }
406
+
407
+
260
408
  def is_ca_trusted() -> bool:
261
409
  """Best-effort check whether the MTRX CA is trusted by the OS."""
262
410
  if not ca_exists():
@@ -265,11 +413,13 @@ def is_ca_trusted() -> bool:
265
413
  try:
266
414
  if system == "Darwin":
267
415
  result = subprocess.run(
268
- ["security", "find-certificate", "-c", "MTRX Cursor Proxy CA"],
416
+ ["security", "find-certificate", "-c", _CA_COMMON_NAME],
269
417
  capture_output=True,
270
418
  timeout=10,
271
419
  )
272
420
  return result.returncode == 0
421
+ if system == "Windows":
422
+ return windows_trust_status(ca_cert_path())["fully_trusted"]
273
423
  except Exception:
274
424
  pass
275
425
  return False
@@ -475,6 +475,8 @@ def configure_cursor_proxy_settings(
475
475
  # Inject NODE_EXTRA_CA_CERTS into integrated terminal env so Cursor's
476
476
  # Node.js runtime trusts our CA. Cursor itself reads this from the
477
477
  # process environment, but setting it here covers more cases.
478
+ # Explicitly unset HTTPS_PROXY/HTTP_PROXY in terminals so child processes
479
+ # (e.g. Claude Code) do NOT inherit the proxy from the Cursor process.
478
480
  for env_key in (
479
481
  "terminal.integrated.env.osx",
480
482
  "terminal.integrated.env.linux",
@@ -484,6 +486,11 @@ def configure_cursor_proxy_settings(
484
486
  if not isinstance(env_block, dict):
485
487
  env_block = {}
486
488
  env_block["NODE_EXTRA_CA_CERTS"] = ca_cert_path
489
+ # null unsets an inherited env var in VS Code integrated terminals
490
+ env_block["HTTPS_PROXY"] = None
491
+ env_block["HTTP_PROXY"] = None
492
+ env_block["https_proxy"] = None
493
+ env_block["http_proxy"] = None
487
494
  settings[env_key] = env_block
488
495
 
489
496
  _write_settings_json(settings)
@@ -511,6 +518,8 @@ def restore_cursor_proxy_settings(previous: dict) -> bool:
511
518
  env_block = settings.get(env_key)
512
519
  if isinstance(env_block, dict):
513
520
  env_block.pop("NODE_EXTRA_CA_CERTS", None)
521
+ for _k in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"):
522
+ env_block.pop(_k, None)
514
523
  if not env_block:
515
524
  settings.pop(env_key, None)
516
525
  else:
@@ -85,14 +85,16 @@ def launch_cursor_with_proxy(
85
85
  env["HTTP_PROXY"] = proxy_url
86
86
  env["HTTPS_PROXY"] = proxy_url
87
87
  env["NODE_EXTRA_CA_CERTS"] = str(ca_cert_path)
88
+ launch_args = [exe]
88
89
 
89
90
  kwargs: dict = {}
90
91
  if platform.system() == "Windows":
91
92
  kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
93
+ launch_args.append("--disable-quic")
92
94
 
93
95
  try:
94
96
  subprocess.Popen(
95
- [exe],
97
+ launch_args,
96
98
  env=env,
97
99
  stdin=subprocess.DEVNULL,
98
100
  stdout=subprocess.DEVNULL,