mtrx-cli 0.1.1 → 0.1.2
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/cli/launcher.py +150 -56
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import base64
|
|
4
4
|
import hashlib
|
|
5
5
|
import datetime as dt
|
|
6
|
+
import httpx
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
8
9
|
import platform
|
|
@@ -169,6 +170,12 @@ def prepare_routed_setup(
|
|
|
169
170
|
changed = True
|
|
170
171
|
if route == "matrx" and _persist_workspace_binding_from_env(state, env):
|
|
171
172
|
changed = True
|
|
173
|
+
if route == "matrx" and tool == "claude":
|
|
174
|
+
mx_key, _ = _resolve_matrx_route_key(state, env)
|
|
175
|
+
if _approve_claude_custom_api_key(mx_key):
|
|
176
|
+
changed = True
|
|
177
|
+
if _sync_claude_subscription_to_matrx(state, env):
|
|
178
|
+
changed = True
|
|
172
179
|
|
|
173
180
|
if _sync_tool_route_config(state, tool=tool, route=route):
|
|
174
181
|
changed = True
|
|
@@ -200,6 +207,10 @@ def claude_settings_path() -> Path:
|
|
|
200
207
|
return Path.home() / ".claude" / "settings.json"
|
|
201
208
|
|
|
202
209
|
|
|
210
|
+
def claude_state_path() -> Path:
|
|
211
|
+
return Path.home() / ".claude.json"
|
|
212
|
+
|
|
213
|
+
|
|
203
214
|
def codex_config_path() -> Path:
|
|
204
215
|
return Path.home() / ".codex" / "config.toml"
|
|
205
216
|
|
|
@@ -247,6 +258,102 @@ def read_codex_access_token() -> str | None:
|
|
|
247
258
|
return token.strip()
|
|
248
259
|
|
|
249
260
|
|
|
261
|
+
def _read_claude_app_state() -> dict:
|
|
262
|
+
path = claude_state_path()
|
|
263
|
+
if not path.exists():
|
|
264
|
+
return {}
|
|
265
|
+
try:
|
|
266
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
267
|
+
except (json.JSONDecodeError, OSError):
|
|
268
|
+
return {}
|
|
269
|
+
return data if isinstance(data, dict) else {}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _write_claude_app_state(data: dict) -> None:
|
|
273
|
+
path = claude_state_path()
|
|
274
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _approve_claude_custom_api_key(raw_key: str) -> bool:
|
|
279
|
+
if not raw_key:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
state = _read_claude_app_state()
|
|
283
|
+
custom = state.setdefault("customApiKeyResponses", {})
|
|
284
|
+
approved = custom.get("approved")
|
|
285
|
+
rejected = custom.get("rejected")
|
|
286
|
+
if not isinstance(approved, list):
|
|
287
|
+
approved = []
|
|
288
|
+
if not isinstance(rejected, list):
|
|
289
|
+
rejected = []
|
|
290
|
+
|
|
291
|
+
changed = False
|
|
292
|
+
if raw_key not in approved:
|
|
293
|
+
approved.append(raw_key)
|
|
294
|
+
changed = True
|
|
295
|
+
if raw_key in rejected:
|
|
296
|
+
rejected = [value for value in rejected if value != raw_key]
|
|
297
|
+
changed = True
|
|
298
|
+
|
|
299
|
+
if not changed:
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
custom["approved"] = approved
|
|
303
|
+
custom["rejected"] = rejected
|
|
304
|
+
_write_claude_app_state(state)
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _resolve_claude_subscription_token(state: dict) -> str:
|
|
309
|
+
token = (read_claude_oauth_token() or "").strip()
|
|
310
|
+
if token:
|
|
311
|
+
return token
|
|
312
|
+
return (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _sync_claude_subscription_to_matrx(state: dict, env: dict[str, str]) -> bool:
|
|
316
|
+
token = _resolve_claude_subscription_token(state)
|
|
317
|
+
if not token:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
mx_key, _ = _resolve_matrx_route_key(state, env)
|
|
321
|
+
if not mx_key:
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
auth_cfg = state.setdefault("auth", {}).setdefault("claude_code", {})
|
|
325
|
+
token_fp = _fingerprint_secret(token)
|
|
326
|
+
key_fp = _fingerprint_secret(mx_key)
|
|
327
|
+
base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
auth_cfg.get("server_synced_token_fingerprint") == token_fp
|
|
331
|
+
and auth_cfg.get("server_synced_matrx_key_fingerprint") == key_fp
|
|
332
|
+
and auth_cfg.get("server_synced_base_url") == base_url
|
|
333
|
+
):
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
with httpx.Client(timeout=15) as client:
|
|
338
|
+
response = client.post(
|
|
339
|
+
f"{base_url.rstrip('/')}/v1/subscriptions/claude-code",
|
|
340
|
+
headers={
|
|
341
|
+
"X-Matrx-Key": mx_key,
|
|
342
|
+
"Content-Type": "application/json",
|
|
343
|
+
},
|
|
344
|
+
json={"token": token},
|
|
345
|
+
)
|
|
346
|
+
response.raise_for_status()
|
|
347
|
+
except httpx.HTTPError:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
auth_cfg["server_synced_token_fingerprint"] = token_fp
|
|
351
|
+
auth_cfg["server_synced_matrx_key_fingerprint"] = key_fp
|
|
352
|
+
auth_cfg["server_synced_base_url"] = base_url
|
|
353
|
+
auth_cfg["server_synced_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
|
|
250
357
|
def _resolve_matrx_route_key(
|
|
251
358
|
state: dict,
|
|
252
359
|
env: dict[str, str],
|
|
@@ -372,20 +479,20 @@ def _build_claude_env(
|
|
|
372
479
|
) -> tuple[dict[str, str], str]:
|
|
373
480
|
matrx = state["auth"]["matrx"]
|
|
374
481
|
anthropic = state["auth"]["anthropic"]
|
|
375
|
-
|
|
376
|
-
proxy_base =
|
|
482
|
+
proxy_root = ensure_root_url(matrx.get("base_url"))
|
|
483
|
+
proxy_base = ensure_v1_url(matrx.get("base_url"))
|
|
377
484
|
mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
|
|
378
485
|
direct_key = (anthropic.get("key") or "").strip()
|
|
379
|
-
oauth_mode = _claude_effective_oauth_mode(state, env)
|
|
380
486
|
|
|
381
487
|
if route == "matrx":
|
|
382
488
|
if not mx_key:
|
|
383
489
|
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
384
490
|
env.pop("MTRX_KEY", None)
|
|
385
|
-
env
|
|
491
|
+
env.pop("MATRX_CLAUDE_MODE", None)
|
|
492
|
+
env["MATRX_BASE_URL"] = proxy_root
|
|
386
493
|
env["MATRX_API_KEY"] = mx_key
|
|
387
|
-
# Claude Code gateway uses root URL, not /v1
|
|
388
494
|
env["ANTHROPIC_BASE_URL"] = proxy_base
|
|
495
|
+
env["ANTHROPIC_API_KEY"] = mx_key
|
|
389
496
|
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
390
497
|
session_id = str(uuid.uuid4())
|
|
391
498
|
# Evolutionary scaffolding: env snapshot for AI context injection
|
|
@@ -393,7 +500,6 @@ def _build_claude_env(
|
|
|
393
500
|
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
394
501
|
custom_headers = "\n".join(
|
|
395
502
|
[
|
|
396
|
-
f"x-matrx-key: {mx_key}",
|
|
397
503
|
"x-matrx-agent-id: claude-cli",
|
|
398
504
|
"x-matrx-provider: claude_code",
|
|
399
505
|
f"x-matrx-session-id: {session_id}",
|
|
@@ -405,33 +511,34 @@ def _build_claude_env(
|
|
|
405
511
|
custom_headers += f"\nx-matrx-project-id: {project_id}"
|
|
406
512
|
if env_b64:
|
|
407
513
|
custom_headers += f"\nx-matrx-env: {env_b64}"
|
|
408
|
-
# Always send the matrx key via ANTHROPIC_CUSTOM_HEADERS so it arrives on
|
|
409
|
-
# the dedicated X-Matrx-Key header. This keeps the Authorization slot free
|
|
410
|
-
# for the real Anthropic credential (OAuth token or API key) so the proxy can
|
|
411
|
-
# forward it to Anthropic without confusing the matrx key with a provider key.
|
|
412
514
|
env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
|
|
413
515
|
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
414
|
-
if oauth_mode:
|
|
415
|
-
# OAuth token flows through natively via Authorization: Bearer sk-ant-oat01-*
|
|
416
|
-
env.pop("ANTHROPIC_API_KEY", None)
|
|
417
|
-
else:
|
|
418
|
-
# Non-OAuth: if a saved Anthropic API key is available, set it so the SDK
|
|
419
|
-
# sends x-api-key to the proxy and the proxy can forward it upstream.
|
|
420
|
-
# If no key is saved, the proxy will fall back to the org vault.
|
|
421
|
-
if direct_key:
|
|
422
|
-
env["ANTHROPIC_API_KEY"] = direct_key
|
|
423
|
-
else:
|
|
424
|
-
env.pop("ANTHROPIC_API_KEY", None)
|
|
425
516
|
return env, matrx_auth_source
|
|
426
517
|
|
|
427
518
|
# Direct route: clear any matrx-managed env vars
|
|
428
519
|
env.pop("MTRX_KEY", None)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
520
|
+
env.pop("MATRX_CLAUDE_MODE", None)
|
|
521
|
+
env.pop("MATRX_BASE_URL", None)
|
|
522
|
+
env.pop("MATRX_API_KEY", None)
|
|
523
|
+
for candidate in {proxy_root, proxy_base}:
|
|
524
|
+
_clear_if_matches(env, "ANTHROPIC_BASE_URL", candidate)
|
|
525
|
+
|
|
526
|
+
anthropic_api_key = (env.get("ANTHROPIC_API_KEY") or "").strip()
|
|
527
|
+
if anthropic_api_key.startswith("mx_") or anthropic_api_key == mx_key:
|
|
528
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
529
|
+
|
|
530
|
+
anthropic_auth_token = (env.get("ANTHROPIC_AUTH_TOKEN") or "").strip()
|
|
531
|
+
if (
|
|
532
|
+
anthropic_auth_token == mx_key
|
|
533
|
+
or anthropic_auth_token.startswith("mx_")
|
|
534
|
+
or anthropic_auth_token.lower().startswith("bearer mx_")
|
|
535
|
+
):
|
|
536
|
+
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
537
|
+
|
|
538
|
+
custom_headers = (env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
|
|
539
|
+
if "x-matrx-" in custom_headers.lower():
|
|
434
540
|
env.pop("ANTHROPIC_CUSTOM_HEADERS", None)
|
|
541
|
+
|
|
435
542
|
if claude_oauth_available():
|
|
436
543
|
return env, "local_claude_oauth"
|
|
437
544
|
if env.get("ANTHROPIC_API_KEY"):
|
|
@@ -451,11 +558,12 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
|
451
558
|
return
|
|
452
559
|
|
|
453
560
|
base_url = (plan.env.get("ANTHROPIC_BASE_URL") or "").strip()
|
|
561
|
+
expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
454
562
|
if not base_url:
|
|
455
563
|
raise ValueError("Claude Matrx route is missing ANTHROPIC_BASE_URL")
|
|
456
|
-
if base_url
|
|
564
|
+
if base_url != expected_base_url:
|
|
457
565
|
raise ValueError(
|
|
458
|
-
"Claude Matrx route must use the
|
|
566
|
+
"Claude Matrx route must use the Matrx /v1 base URL. "
|
|
459
567
|
f"Got: {base_url}"
|
|
460
568
|
)
|
|
461
569
|
|
|
@@ -463,24 +571,24 @@ def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
|
463
571
|
if not mx_key.startswith("mx_"):
|
|
464
572
|
raise ValueError("Claude Matrx route is missing a valid MATRX_API_KEY")
|
|
465
573
|
|
|
466
|
-
|
|
467
|
-
|
|
574
|
+
anthropic_key = (plan.env.get("ANTHROPIC_API_KEY") or "").strip()
|
|
575
|
+
if anthropic_key != mx_key:
|
|
576
|
+
raise ValueError("Claude Matrx route must set ANTHROPIC_API_KEY to the same mx_ key as MATRX_API_KEY")
|
|
577
|
+
|
|
468
578
|
custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
|
|
469
|
-
if not custom_headers
|
|
470
|
-
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS
|
|
579
|
+
if not custom_headers:
|
|
580
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS")
|
|
471
581
|
lowered_headers = custom_headers.lower()
|
|
472
582
|
if "x-matrx-session-id:" not in lowered_headers:
|
|
473
583
|
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
|
|
474
584
|
if "x-matrx-provider: claude_code" not in lowered_headers:
|
|
475
585
|
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Provider=claude_code")
|
|
586
|
+
if "x-matrx-agent-id: claude-cli" not in lowered_headers:
|
|
587
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Agent-Id=claude-cli")
|
|
476
588
|
|
|
477
589
|
if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
|
|
478
590
|
raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")
|
|
479
591
|
|
|
480
|
-
if _claude_effective_oauth_mode(state, plan.env):
|
|
481
|
-
if plan.env.get("ANTHROPIC_API_KEY"):
|
|
482
|
-
raise ValueError("Claude Matrx OAuth route should not set ANTHROPIC_API_KEY")
|
|
483
|
-
|
|
484
592
|
|
|
485
593
|
def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
486
594
|
if plan.route != "matrx":
|
|
@@ -529,16 +637,18 @@ def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
|
|
|
529
637
|
]
|
|
530
638
|
|
|
531
639
|
if plan.tool == "claude":
|
|
532
|
-
base_url =
|
|
640
|
+
base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
533
641
|
custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
|
|
534
|
-
|
|
642
|
+
anthropic_key = (plan.env.get("ANTHROPIC_API_KEY") or "").strip()
|
|
535
643
|
return [
|
|
536
644
|
"Launching claude via Matrx",
|
|
537
645
|
f" base_url: {base_url or DEFAULT_MATRX_BASE_URL}",
|
|
538
646
|
f" auth_source: {plan.auth_source}",
|
|
539
|
-
f" oauth_mode: {_claude_effective_oauth_mode(state, plan.env)}",
|
|
540
647
|
f" custom_headers_present: {bool(custom_headers)}",
|
|
541
|
-
f"
|
|
648
|
+
f" proxy_key_present: {anthropic_key.startswith('mx_')}",
|
|
649
|
+
f" subscription_token_available: {bool(_resolve_claude_subscription_token(state))}",
|
|
650
|
+
" runtime_route: env injection",
|
|
651
|
+
" persistent_route: disabled",
|
|
542
652
|
]
|
|
543
653
|
|
|
544
654
|
return []
|
|
@@ -592,22 +702,6 @@ def _clear_if_matches(env: dict[str, str], key: str, expected: str) -> None:
|
|
|
592
702
|
env.pop(key, None)
|
|
593
703
|
|
|
594
704
|
|
|
595
|
-
def _claude_uses_oauth(state: dict) -> bool:
|
|
596
|
-
if read_claude_oauth_token():
|
|
597
|
-
return True
|
|
598
|
-
imported = (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
|
|
599
|
-
return bool(imported)
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
def _claude_effective_oauth_mode(state: dict, env: dict[str, str] | None = None) -> bool:
|
|
603
|
-
requested = ((env or {}).get("MATRX_CLAUDE_MODE") or "").strip().lower()
|
|
604
|
-
if requested == "oauth":
|
|
605
|
-
return True
|
|
606
|
-
if requested == "api-key":
|
|
607
|
-
return False
|
|
608
|
-
return _claude_uses_oauth(state)
|
|
609
|
-
|
|
610
|
-
|
|
611
705
|
def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
|
|
612
706
|
if tool == "claude":
|
|
613
707
|
return _cleanup_claude_managed_config(state)
|