mtrx-cli 0.1.24 → 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 +1 -1
- package/src/matrx/__init__.py +1 -1
- package/src/matrx/cli/cursor_ca.py +184 -34
- package/src/matrx/cli/cursor_config.py +9 -0
- package/src/matrx/cli/cursor_launcher.py +3 -1
- package/src/matrx/cli/cursor_proxy.py +303 -101
- package/src/matrx/cli/cursor_reroute.py +490 -8
- package/src/matrx/cli/launcher.py +4 -0
- package/src/matrx/cli/main.py +384 -59
- package/src/matrx/cli/state.py +11 -0
package/package.json
CHANGED
package/src/matrx/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
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,
|
|
67
|
-
x509.NameAttribute(NameOID.COMMON_NAME,
|
|
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=
|
|
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,
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
186
|
+
material = self._cache.get(hostname)
|
|
187
|
+
if material is not None:
|
|
188
|
+
return material
|
|
154
189
|
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 =
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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",
|
|
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
|
-
|
|
97
|
+
launch_args,
|
|
96
98
|
env=env,
|
|
97
99
|
stdin=subprocess.DEVNULL,
|
|
98
100
|
stdout=subprocess.DEVNULL,
|