mtrx-cli 0.1.0
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/README.md +32 -0
- package/bin/mtrx.js +111 -0
- package/package.json +34 -0
- package/src/matrx/__init__.py +1 -0
- package/src/matrx/cli/__init__.py +2 -0
- package/src/matrx/cli/launcher.py +796 -0
- package/src/matrx/cli/main.py +510 -0
- package/src/matrx/cli/state.py +291 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import uuid
|
|
13
|
+
try:
|
|
14
|
+
import tomllib
|
|
15
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility
|
|
16
|
+
tomllib = None
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from matrx.cli.state import (
|
|
21
|
+
DEFAULT_MATRX_BASE_URL,
|
|
22
|
+
ensure_root_url,
|
|
23
|
+
ensure_v1_url,
|
|
24
|
+
get_workspace_binding,
|
|
25
|
+
set_workspace_binding,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _capture_env_snapshot() -> dict:
|
|
30
|
+
"""Capture OS, shell, cwd, venv, node for evolutionary scaffolding injection."""
|
|
31
|
+
import subprocess as _sp
|
|
32
|
+
out: dict = {
|
|
33
|
+
"os": platform.system(),
|
|
34
|
+
"shell": os.environ.get("SHELL", os.environ.get("COMSPEC", "")),
|
|
35
|
+
"cwd": os.getcwd(),
|
|
36
|
+
}
|
|
37
|
+
out["venv"] = os.environ.get("VIRTUAL_ENV", "") or None
|
|
38
|
+
if not out["venv"]:
|
|
39
|
+
out["venv_active"] = False
|
|
40
|
+
node = shutil.which("node")
|
|
41
|
+
if node:
|
|
42
|
+
try:
|
|
43
|
+
r = _sp.run([node, "-v"], capture_output=True, text=True, timeout=2)
|
|
44
|
+
out["node"] = r.stdout.strip() if r.returncode == 0 else None
|
|
45
|
+
except Exception:
|
|
46
|
+
out["node"] = None
|
|
47
|
+
else:
|
|
48
|
+
out["node"] = None
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MATRX_ENV_KEYS = {
|
|
53
|
+
"MTRX_KEY",
|
|
54
|
+
"OPENAI_BASE_URL",
|
|
55
|
+
"OPENAI_API_BASE",
|
|
56
|
+
"OPENAI_API_KEY",
|
|
57
|
+
"ANTHROPIC_BASE_URL",
|
|
58
|
+
"ANTHROPIC_API_KEY",
|
|
59
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
60
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
MTRX_CODEX_BLOCK_START = "# >>> mtrx managed codex route >>>"
|
|
64
|
+
MTRX_CODEX_BLOCK_END = "# <<< mtrx managed codex route <<<"
|
|
65
|
+
VALID_ROUTES = {"direct", "matrx"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class LaunchPlan:
|
|
70
|
+
tool: str
|
|
71
|
+
route: str
|
|
72
|
+
executable: str
|
|
73
|
+
args: list[str]
|
|
74
|
+
env: dict[str, str]
|
|
75
|
+
auth_source: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def configured_route(state: dict, tool: str) -> str | None:
|
|
79
|
+
route = state.get("defaults", {}).get(tool)
|
|
80
|
+
if route in VALID_ROUTES:
|
|
81
|
+
return str(route)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_route(state: dict, tool: str, route_override: str | None) -> str:
|
|
86
|
+
if route_override:
|
|
87
|
+
return route_override
|
|
88
|
+
return configured_route(state, tool) or "direct"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def initialize_first_launch_route(state: dict, tool: str, route_override: str | None) -> bool:
|
|
92
|
+
if route_override:
|
|
93
|
+
return False
|
|
94
|
+
if configured_route(state, tool) is not None:
|
|
95
|
+
return False
|
|
96
|
+
state.setdefault("defaults", {})[tool] = "matrx"
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_executable(tool: str) -> str | None:
|
|
101
|
+
candidates = [tool]
|
|
102
|
+
if tool == "claude":
|
|
103
|
+
candidates.extend(["claude.exe", "claude.cmd"])
|
|
104
|
+
if tool == "codex":
|
|
105
|
+
candidates.extend(["codex.exe", "codex.cmd"])
|
|
106
|
+
for candidate in candidates:
|
|
107
|
+
found = shutil.which(candidate)
|
|
108
|
+
if found:
|
|
109
|
+
return found
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_launch_plan(
|
|
114
|
+
state: dict,
|
|
115
|
+
*,
|
|
116
|
+
tool: str,
|
|
117
|
+
route_override: str | None = None,
|
|
118
|
+
passthrough_args: list[str] | None = None,
|
|
119
|
+
base_env: dict[str, str] | None = None,
|
|
120
|
+
) -> LaunchPlan:
|
|
121
|
+
executable = find_executable(tool)
|
|
122
|
+
if not executable:
|
|
123
|
+
raise ValueError(f"{tool} executable not found in PATH")
|
|
124
|
+
|
|
125
|
+
route = resolve_route(state, tool, route_override)
|
|
126
|
+
env = dict(base_env or os.environ)
|
|
127
|
+
auth_source = ""
|
|
128
|
+
launch_args = list(passthrough_args or [])
|
|
129
|
+
|
|
130
|
+
if tool == "codex":
|
|
131
|
+
env, auth_source, launch_args = _build_codex_env(
|
|
132
|
+
state,
|
|
133
|
+
route,
|
|
134
|
+
env,
|
|
135
|
+
launch_args,
|
|
136
|
+
)
|
|
137
|
+
elif tool == "claude":
|
|
138
|
+
env, auth_source = _build_claude_env(state, route, env)
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError(f"Unsupported tool: {tool}")
|
|
141
|
+
|
|
142
|
+
return LaunchPlan(
|
|
143
|
+
tool=tool,
|
|
144
|
+
route=route,
|
|
145
|
+
executable=executable,
|
|
146
|
+
args=launch_args,
|
|
147
|
+
env=env,
|
|
148
|
+
auth_source=auth_source,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def prepare_routed_setup(
|
|
153
|
+
state: dict,
|
|
154
|
+
*,
|
|
155
|
+
tool: str,
|
|
156
|
+
route_override: str | None = None,
|
|
157
|
+
base_env: dict[str, str] | None = None,
|
|
158
|
+
) -> tuple[dict, bool]:
|
|
159
|
+
"""
|
|
160
|
+
Inline setup for routed launches.
|
|
161
|
+
|
|
162
|
+
Returns (mutated_state, changed).
|
|
163
|
+
Raises ValueError when the launch should abort.
|
|
164
|
+
"""
|
|
165
|
+
route = resolve_route(state, tool, route_override)
|
|
166
|
+
changed = False
|
|
167
|
+
env = dict(base_env or os.environ)
|
|
168
|
+
if route == "matrx" and _ensure_matrx_auth(state, env=env):
|
|
169
|
+
changed = True
|
|
170
|
+
if route == "matrx" and _persist_workspace_binding_from_env(state, env):
|
|
171
|
+
changed = True
|
|
172
|
+
|
|
173
|
+
if _sync_tool_route_config(state, tool=tool, route=route):
|
|
174
|
+
changed = True
|
|
175
|
+
|
|
176
|
+
if route != "matrx":
|
|
177
|
+
return state, changed
|
|
178
|
+
|
|
179
|
+
return state, changed
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def launch(plan: LaunchPlan) -> int:
|
|
183
|
+
result = subprocess.run([plan.executable, *plan.args], env=plan.env, check=False)
|
|
184
|
+
return int(result.returncode)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
188
|
+
"""Validate launch plan before spawning the process."""
|
|
189
|
+
if plan.tool == "claude":
|
|
190
|
+
_validate_claude_launch_plan(plan, state)
|
|
191
|
+
if plan.tool == "codex":
|
|
192
|
+
_validate_codex_launch_plan(plan, state)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def claude_credentials_path() -> Path:
|
|
196
|
+
return Path.home() / ".claude" / ".credentials.json"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def claude_settings_path() -> Path:
|
|
200
|
+
return Path.home() / ".claude" / "settings.json"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def codex_config_path() -> Path:
|
|
204
|
+
return Path.home() / ".codex" / "config.toml"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def codex_auth_path() -> Path:
|
|
208
|
+
return Path.home() / ".codex" / "auth.json"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def claude_oauth_available() -> bool:
|
|
212
|
+
token = read_claude_oauth_token()
|
|
213
|
+
return bool(token)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_claude_oauth_token() -> str | None:
|
|
217
|
+
path = claude_credentials_path()
|
|
218
|
+
if not path.exists():
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
222
|
+
except (json.JSONDecodeError, OSError):
|
|
223
|
+
return None
|
|
224
|
+
oauth = data.get("claudeAiOauth")
|
|
225
|
+
if not isinstance(oauth, dict):
|
|
226
|
+
return None
|
|
227
|
+
token = oauth.get("accessToken")
|
|
228
|
+
if not isinstance(token, str) or not token.strip():
|
|
229
|
+
return None
|
|
230
|
+
return token.strip()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def read_codex_access_token() -> str | None:
|
|
234
|
+
path = codex_auth_path()
|
|
235
|
+
if not path.exists():
|
|
236
|
+
return None
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
239
|
+
except (json.JSONDecodeError, OSError):
|
|
240
|
+
return None
|
|
241
|
+
tokens = data.get("tokens")
|
|
242
|
+
if not isinstance(tokens, dict):
|
|
243
|
+
return None
|
|
244
|
+
token = tokens.get("access_token")
|
|
245
|
+
if not isinstance(token, str) or not token.strip():
|
|
246
|
+
return None
|
|
247
|
+
return token.strip()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_matrx_route_key(
|
|
251
|
+
state: dict,
|
|
252
|
+
env: dict[str, str],
|
|
253
|
+
) -> tuple[str, str]:
|
|
254
|
+
env_key = (env.get("MTRX_KEY") or "").strip()
|
|
255
|
+
if env_key:
|
|
256
|
+
return env_key, "env_matrx_key"
|
|
257
|
+
|
|
258
|
+
binding = get_workspace_binding(state, cwd=_workspace_cwd(env))
|
|
259
|
+
binding_key = ((binding or {}).get("matrx_key") or "").strip()
|
|
260
|
+
if binding_key:
|
|
261
|
+
return binding_key, "workspace_matrx_key"
|
|
262
|
+
|
|
263
|
+
saved_key = (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
|
|
264
|
+
if saved_key:
|
|
265
|
+
return saved_key, "saved_matrx_key"
|
|
266
|
+
|
|
267
|
+
return "", "missing_matrx_key"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _workspace_cwd(env: dict[str, str]) -> str:
|
|
271
|
+
return (env.get("PWD") or os.getcwd()).strip()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _resolve_matrx_context_overrides(
|
|
275
|
+
state: dict,
|
|
276
|
+
env: dict[str, str],
|
|
277
|
+
) -> tuple[str, str]:
|
|
278
|
+
binding = get_workspace_binding(state, cwd=_workspace_cwd(env)) or {}
|
|
279
|
+
group_id = (env.get("MTRX_GROUP_ID") or binding.get("group_id") or "").strip()
|
|
280
|
+
project_id = (env.get("MTRX_PROJECT_ID") or binding.get("project_id") or "").strip()
|
|
281
|
+
return group_id, project_id
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _persist_workspace_binding_from_env(state: dict, env: dict[str, str]) -> bool:
|
|
285
|
+
env_key = (env.get("MTRX_KEY") or "").strip()
|
|
286
|
+
env_project = (env.get("MTRX_PROJECT_ID") or "").strip()
|
|
287
|
+
env_group = (env.get("MTRX_GROUP_ID") or "").strip()
|
|
288
|
+
saved_personal_key = (state.get("auth", {}).get("matrx", {}).get("key") or "").strip()
|
|
289
|
+
|
|
290
|
+
if not any((env_key, env_project, env_group)):
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
if env_key and env_key == saved_personal_key and not env_project and not env_group:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
return set_workspace_binding(
|
|
297
|
+
state,
|
|
298
|
+
cwd=_workspace_cwd(env),
|
|
299
|
+
matrx_key=env_key if "MTRX_KEY" in env else None,
|
|
300
|
+
project_id=env_project if "MTRX_PROJECT_ID" in env else None,
|
|
301
|
+
group_id=env_group if "MTRX_GROUP_ID" in env else None,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _build_codex_env(
|
|
305
|
+
state: dict,
|
|
306
|
+
route: str,
|
|
307
|
+
env: dict[str, str],
|
|
308
|
+
passthrough_args: list[str],
|
|
309
|
+
) -> tuple[dict[str, str], str, list[str]]:
|
|
310
|
+
matrx = state["auth"]["matrx"]
|
|
311
|
+
openai = state["auth"]["openai"]
|
|
312
|
+
proxy_base = ensure_v1_url(matrx.get("base_url"))
|
|
313
|
+
mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
|
|
314
|
+
direct_key = (openai.get("key") or "").strip()
|
|
315
|
+
|
|
316
|
+
if route == "matrx":
|
|
317
|
+
if not mx_key:
|
|
318
|
+
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
319
|
+
access_token = read_codex_access_token()
|
|
320
|
+
if not access_token:
|
|
321
|
+
raise ValueError("Codex login required. Run: codex login")
|
|
322
|
+
for key in MATRX_ENV_KEYS:
|
|
323
|
+
env.pop(key, None)
|
|
324
|
+
env_snap = _capture_env_snapshot()
|
|
325
|
+
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
326
|
+
session_id = str(uuid.uuid4())
|
|
327
|
+
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
328
|
+
header_parts = [
|
|
329
|
+
f'"Authorization" = "Bearer {access_token}"',
|
|
330
|
+
f'"X-Matrx-Key" = "{mx_key}"',
|
|
331
|
+
'"X-Matrx-Agent-Id" = "codex-cli"',
|
|
332
|
+
'"X-Matrx-Provider" = "codex"',
|
|
333
|
+
f'"X-Matrx-Session-Id" = "{session_id}"',
|
|
334
|
+
]
|
|
335
|
+
if group_id:
|
|
336
|
+
header_parts.append(f'"X-Matrx-Group" = "{group_id}"')
|
|
337
|
+
if project_id:
|
|
338
|
+
header_parts.append(f'"X-Matrx-Project-Id" = "{project_id}"')
|
|
339
|
+
if env_b64:
|
|
340
|
+
header_parts.append(f'"X-Matrx-Env" = "{env_b64}"')
|
|
341
|
+
headers_str = ", ".join(header_parts)
|
|
342
|
+
return env, matrx_auth_source, [
|
|
343
|
+
"-c",
|
|
344
|
+
"model_provider=matrx",
|
|
345
|
+
"-c",
|
|
346
|
+
'model_providers.matrx.name="Matrx Proxy"',
|
|
347
|
+
"-c",
|
|
348
|
+
f'model_providers.matrx.base_url="{proxy_base}"',
|
|
349
|
+
"-c",
|
|
350
|
+
'model_providers.matrx.wire_api="responses"',
|
|
351
|
+
"-c",
|
|
352
|
+
f'model_providers.matrx.http_headers={{ {headers_str} }}',
|
|
353
|
+
*passthrough_args,
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
_clear_if_matches(env, "OPENAI_BASE_URL", proxy_base)
|
|
357
|
+
_clear_if_matches(env, "OPENAI_API_BASE", proxy_base)
|
|
358
|
+
_clear_if_matches(env, "OPENAI_API_KEY", mx_key)
|
|
359
|
+
env.pop("MTRX_KEY", None)
|
|
360
|
+
if env.get("OPENAI_API_KEY"):
|
|
361
|
+
return env, "existing_openai_env", passthrough_args
|
|
362
|
+
if direct_key:
|
|
363
|
+
env["OPENAI_API_KEY"] = direct_key
|
|
364
|
+
return env, "saved_openai_key", passthrough_args
|
|
365
|
+
return env, "existing_codex_auth", passthrough_args
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _build_claude_env(
|
|
369
|
+
state: dict,
|
|
370
|
+
route: str,
|
|
371
|
+
env: dict[str, str],
|
|
372
|
+
) -> tuple[dict[str, str], str]:
|
|
373
|
+
matrx = state["auth"]["matrx"]
|
|
374
|
+
anthropic = state["auth"]["anthropic"]
|
|
375
|
+
# Claude Code gateway uses the root URL, not a /v1-suffixed base.
|
|
376
|
+
proxy_base = ensure_root_url(matrx.get("base_url"))
|
|
377
|
+
mx_key, matrx_auth_source = _resolve_matrx_route_key(state, env)
|
|
378
|
+
direct_key = (anthropic.get("key") or "").strip()
|
|
379
|
+
|
|
380
|
+
if route == "matrx":
|
|
381
|
+
if not mx_key:
|
|
382
|
+
raise ValueError("No Matrx key available. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
383
|
+
env.pop("MTRX_KEY", None)
|
|
384
|
+
env["MATRX_BASE_URL"] = proxy_base
|
|
385
|
+
env["MATRX_API_KEY"] = mx_key
|
|
386
|
+
# Claude Code gateway uses root URL, not /v1
|
|
387
|
+
env["ANTHROPIC_BASE_URL"] = proxy_base
|
|
388
|
+
group_id, project_id = _resolve_matrx_context_overrides(state, env)
|
|
389
|
+
session_id = str(uuid.uuid4())
|
|
390
|
+
# Evolutionary scaffolding: env snapshot for AI context injection
|
|
391
|
+
env_snap = _capture_env_snapshot()
|
|
392
|
+
env_b64 = base64.b64encode(json.dumps(env_snap).encode()).decode() if env_snap else ""
|
|
393
|
+
custom_headers = "\n".join(
|
|
394
|
+
[
|
|
395
|
+
f"x-matrx-key: {mx_key}",
|
|
396
|
+
"x-matrx-agent-id: claude-cli",
|
|
397
|
+
"x-matrx-provider: claude_code",
|
|
398
|
+
f"x-matrx-session-id: {session_id}",
|
|
399
|
+
]
|
|
400
|
+
)
|
|
401
|
+
if group_id:
|
|
402
|
+
custom_headers += f"\nx-matrx-group: {group_id}"
|
|
403
|
+
if project_id:
|
|
404
|
+
custom_headers += f"\nx-matrx-project-id: {project_id}"
|
|
405
|
+
if env_b64:
|
|
406
|
+
custom_headers += f"\nx-matrx-env: {env_b64}"
|
|
407
|
+
# Always send the matrx key via ANTHROPIC_CUSTOM_HEADERS so it arrives on
|
|
408
|
+
# the dedicated X-Matrx-Key header. This keeps the Authorization slot free
|
|
409
|
+
# for the real Anthropic credential (OAuth token or API key) so the proxy can
|
|
410
|
+
# forward it to Anthropic without confusing the matrx key with a provider key.
|
|
411
|
+
env["ANTHROPIC_CUSTOM_HEADERS"] = custom_headers
|
|
412
|
+
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
413
|
+
if _claude_uses_oauth(state):
|
|
414
|
+
# OAuth token flows through natively via Authorization: Bearer sk-ant-oat01-*
|
|
415
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
416
|
+
else:
|
|
417
|
+
# Non-OAuth: if a saved Anthropic API key is available, set it so the SDK
|
|
418
|
+
# sends x-api-key to the proxy and the proxy can forward it upstream.
|
|
419
|
+
# If no key is saved, the proxy will fall back to the org vault.
|
|
420
|
+
if direct_key:
|
|
421
|
+
env["ANTHROPIC_API_KEY"] = direct_key
|
|
422
|
+
else:
|
|
423
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
424
|
+
return env, matrx_auth_source
|
|
425
|
+
|
|
426
|
+
# Direct route: clear any matrx-managed env vars
|
|
427
|
+
env.pop("MTRX_KEY", None)
|
|
428
|
+
_clear_if_matches(env, "ANTHROPIC_BASE_URL", proxy_base)
|
|
429
|
+
_clear_if_matches(env, "ANTHROPIC_API_KEY", mx_key)
|
|
430
|
+
_clear_if_matches(env, "ANTHROPIC_AUTH_TOKEN", mx_key)
|
|
431
|
+
custom_headers = env.get("ANTHROPIC_CUSTOM_HEADERS", "")
|
|
432
|
+
if mx_key and mx_key in custom_headers:
|
|
433
|
+
env.pop("ANTHROPIC_CUSTOM_HEADERS", None)
|
|
434
|
+
if claude_oauth_available():
|
|
435
|
+
return env, "local_claude_oauth"
|
|
436
|
+
if env.get("ANTHROPIC_API_KEY"):
|
|
437
|
+
return env, "existing_anthropic_env"
|
|
438
|
+
if direct_key:
|
|
439
|
+
env["ANTHROPIC_API_KEY"] = direct_key
|
|
440
|
+
return env, "saved_anthropic_key"
|
|
441
|
+
imported = (state["auth"]["claude_code"].get("oauth_token") or "").strip()
|
|
442
|
+
if imported:
|
|
443
|
+
return env, "imported_claude_oauth"
|
|
444
|
+
return env, "existing_claude_auth"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _validate_claude_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
448
|
+
"""Validate Claude launch plan before spawning."""
|
|
449
|
+
if plan.route != "matrx":
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
base_url = (plan.env.get("ANTHROPIC_BASE_URL") or "").strip()
|
|
453
|
+
if not base_url:
|
|
454
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_BASE_URL")
|
|
455
|
+
if base_url.endswith("/v1"):
|
|
456
|
+
raise ValueError(
|
|
457
|
+
"Claude Matrx route must use the gateway root base URL, not a /v1 URL. "
|
|
458
|
+
f"Got: {base_url}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
mx_key = (plan.env.get("MATRX_API_KEY") or "").strip()
|
|
462
|
+
if not mx_key.startswith("mx_"):
|
|
463
|
+
raise ValueError("Claude Matrx route is missing a valid MATRX_API_KEY")
|
|
464
|
+
|
|
465
|
+
# All routes use ANTHROPIC_CUSTOM_HEADERS for matrx key delivery so that
|
|
466
|
+
# the Authorization slot remains free for the real Anthropic credential.
|
|
467
|
+
custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
|
|
468
|
+
if not custom_headers or mx_key not in custom_headers:
|
|
469
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with the Matrx key")
|
|
470
|
+
lowered_headers = custom_headers.lower()
|
|
471
|
+
if "x-matrx-session-id:" not in lowered_headers:
|
|
472
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Session-Id")
|
|
473
|
+
if "x-matrx-provider: claude_code" not in lowered_headers:
|
|
474
|
+
raise ValueError("Claude Matrx route is missing ANTHROPIC_CUSTOM_HEADERS with X-Matrx-Provider=claude_code")
|
|
475
|
+
|
|
476
|
+
if plan.env.get("ANTHROPIC_AUTH_TOKEN"):
|
|
477
|
+
raise ValueError("Claude Matrx route should not set ANTHROPIC_AUTH_TOKEN")
|
|
478
|
+
|
|
479
|
+
if _claude_uses_oauth(state):
|
|
480
|
+
oauth_token = read_claude_oauth_token() or (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
|
|
481
|
+
if not oauth_token:
|
|
482
|
+
raise ValueError("Claude OAuth was selected but no Claude OAuth token is available. Run: mtrx login claude-code --import")
|
|
483
|
+
if plan.env.get("ANTHROPIC_API_KEY"):
|
|
484
|
+
raise ValueError("Claude Matrx OAuth route should not set ANTHROPIC_API_KEY")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _validate_codex_launch_plan(plan: LaunchPlan, state: dict) -> None:
|
|
488
|
+
if plan.route != "matrx":
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
expected_base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
492
|
+
required_args = {
|
|
493
|
+
"model_provider=matrx",
|
|
494
|
+
'model_providers.matrx.name="Matrx Proxy"',
|
|
495
|
+
f'model_providers.matrx.base_url="{expected_base_url}"',
|
|
496
|
+
'model_providers.matrx.wire_api="responses"',
|
|
497
|
+
}
|
|
498
|
+
actual_args = set(plan.args)
|
|
499
|
+
if any(arg not in actual_args for arg in required_args):
|
|
500
|
+
raise ValueError("Codex Matrx route is missing required launch overrides")
|
|
501
|
+
http_headers_arg = next(
|
|
502
|
+
(arg for arg in plan.args if arg.startswith('model_providers.matrx.http_headers=')),
|
|
503
|
+
None,
|
|
504
|
+
)
|
|
505
|
+
if not http_headers_arg or '"Authorization" = "Bearer ' not in http_headers_arg:
|
|
506
|
+
raise ValueError("Codex Matrx route is missing launch-time Authorization bearer forwarding")
|
|
507
|
+
key_match = re.search(r'"X-Matrx-Key"\s*=\s*"([^"]+)"', http_headers_arg)
|
|
508
|
+
if key_match is None or not key_match.group(1).startswith("mx_"):
|
|
509
|
+
raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Key forwarding")
|
|
510
|
+
if '"X-Matrx-Provider" = "codex"' not in http_headers_arg:
|
|
511
|
+
raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Provider=codex")
|
|
512
|
+
if '"X-Matrx-Session-Id"' not in http_headers_arg:
|
|
513
|
+
raise ValueError("Codex Matrx route is missing launch-time X-Matrx-Session-Id")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def describe_launch_plan(plan: LaunchPlan, state: dict) -> list[str]:
|
|
517
|
+
if plan.route != "matrx":
|
|
518
|
+
return []
|
|
519
|
+
|
|
520
|
+
if plan.tool == "codex":
|
|
521
|
+
base_url = ensure_v1_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
522
|
+
return [
|
|
523
|
+
"Launching codex via Matrx",
|
|
524
|
+
f" config: {codex_config_path()}",
|
|
525
|
+
f" base_url: {base_url}",
|
|
526
|
+
f" auth_source: {plan.auth_source}",
|
|
527
|
+
f" codex_auth_path: {codex_auth_path()}",
|
|
528
|
+
f" codex_access_token_present: {bool(read_codex_access_token())}",
|
|
529
|
+
" runtime_route: forced launch overrides",
|
|
530
|
+
" persistent_route: disabled",
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
if plan.tool == "claude":
|
|
534
|
+
base_url = ensure_root_url(state.get("auth", {}).get("matrx", {}).get("base_url"))
|
|
535
|
+
custom_headers = (plan.env.get("ANTHROPIC_CUSTOM_HEADERS") or "").strip()
|
|
536
|
+
api_key_present = bool((plan.env.get("ANTHROPIC_API_KEY") or "").strip())
|
|
537
|
+
return [
|
|
538
|
+
"Launching claude via Matrx",
|
|
539
|
+
f" base_url: {base_url or DEFAULT_MATRX_BASE_URL}",
|
|
540
|
+
f" auth_source: {plan.auth_source}",
|
|
541
|
+
f" oauth_mode: {_claude_uses_oauth(state)}",
|
|
542
|
+
f" custom_headers_present: {bool(custom_headers)}",
|
|
543
|
+
f" api_key_present: {api_key_present}",
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
return []
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def get_tool_config_status(state: dict, tool: str) -> dict[str, str | bool | None]:
|
|
550
|
+
status = (
|
|
551
|
+
state.setdefault("setup", {})
|
|
552
|
+
.setdefault("tool_config", {})
|
|
553
|
+
.setdefault(
|
|
554
|
+
tool,
|
|
555
|
+
{
|
|
556
|
+
"configured": False,
|
|
557
|
+
"verified": False,
|
|
558
|
+
"config_path": None,
|
|
559
|
+
"backup_path": None,
|
|
560
|
+
"original_backup_path": None,
|
|
561
|
+
"config_fingerprint": None,
|
|
562
|
+
"matrx_key_fingerprint": None,
|
|
563
|
+
"last_verified_at": None,
|
|
564
|
+
"previous_model_provider": None,
|
|
565
|
+
"previous_matrx_block": None,
|
|
566
|
+
"previous_values": {},
|
|
567
|
+
},
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
return {
|
|
571
|
+
"configured": bool(status.get("configured")),
|
|
572
|
+
"verified": bool(status.get("verified")),
|
|
573
|
+
"config_path": status.get("config_path"),
|
|
574
|
+
"backup_path": status.get("backup_path"),
|
|
575
|
+
"last_verified_at": status.get("last_verified_at"),
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _ensure_matrx_auth(state: dict, *, env: dict[str, str] | None = None) -> bool:
|
|
580
|
+
key, _ = _resolve_matrx_route_key(state, dict(env or os.environ))
|
|
581
|
+
if not key:
|
|
582
|
+
raise ValueError("Login required. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
583
|
+
if not key.startswith("mx_"):
|
|
584
|
+
raise ValueError("Matrx key is invalid. Run: mtrx login matrx --key mx_... or set MTRX_KEY")
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _fingerprint_secret(secret: str) -> str:
|
|
589
|
+
return hashlib.sha256(secret.encode()).hexdigest()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _clear_if_matches(env: dict[str, str], key: str, expected: str) -> None:
|
|
593
|
+
if expected and env.get(key) == expected:
|
|
594
|
+
env.pop(key, None)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _claude_uses_oauth(state: dict) -> bool:
|
|
598
|
+
if read_claude_oauth_token():
|
|
599
|
+
return True
|
|
600
|
+
imported = (state.get("auth", {}).get("claude_code", {}).get("oauth_token") or "").strip()
|
|
601
|
+
return bool(imported)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _sync_tool_route_config(state: dict, *, tool: str, route: str) -> bool:
|
|
605
|
+
if tool == "claude":
|
|
606
|
+
return _cleanup_claude_managed_config(state)
|
|
607
|
+
if tool == "codex":
|
|
608
|
+
return _sync_codex_route_config(state, route=route)
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _tool_config_entry(state: dict, tool: str) -> dict:
|
|
613
|
+
return (
|
|
614
|
+
state.setdefault("setup", {})
|
|
615
|
+
.setdefault("tool_config", {})
|
|
616
|
+
.setdefault(
|
|
617
|
+
tool,
|
|
618
|
+
{
|
|
619
|
+
"configured": False,
|
|
620
|
+
"verified": False,
|
|
621
|
+
"config_path": None,
|
|
622
|
+
"backup_path": None,
|
|
623
|
+
"original_backup_path": None,
|
|
624
|
+
"config_fingerprint": None,
|
|
625
|
+
"matrx_key_fingerprint": None,
|
|
626
|
+
"last_verified_at": None,
|
|
627
|
+
"previous_model_provider": None,
|
|
628
|
+
"previous_matrx_block": None,
|
|
629
|
+
"previous_values": {},
|
|
630
|
+
},
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _read_json_file(path: Path) -> dict:
|
|
636
|
+
if not path.exists():
|
|
637
|
+
return {}
|
|
638
|
+
try:
|
|
639
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
640
|
+
except json.JSONDecodeError as exc:
|
|
641
|
+
raise ValueError(f"Failed to parse {path}") from exc
|
|
642
|
+
if not isinstance(data, dict):
|
|
643
|
+
raise ValueError(f"Expected {path} to contain a JSON object")
|
|
644
|
+
return data
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _write_json_file(path: Path, data: dict) -> None:
|
|
648
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
649
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _cleanup_claude_managed_config(state: dict) -> bool:
|
|
653
|
+
path = claude_settings_path()
|
|
654
|
+
entry = _tool_config_entry(state, "claude")
|
|
655
|
+
entry["config_path"] = str(path)
|
|
656
|
+
|
|
657
|
+
if not path.exists():
|
|
658
|
+
entry["configured"] = False
|
|
659
|
+
entry["verified"] = True
|
|
660
|
+
entry["config_fingerprint"] = None
|
|
661
|
+
entry["matrx_key_fingerprint"] = None
|
|
662
|
+
entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
data = _read_json_file(path)
|
|
666
|
+
env = data.get("env")
|
|
667
|
+
if env is None:
|
|
668
|
+
entry["configured"] = False
|
|
669
|
+
entry["verified"] = True
|
|
670
|
+
entry["config_fingerprint"] = _fingerprint_secret(json.dumps(data, sort_keys=True))
|
|
671
|
+
entry["matrx_key_fingerprint"] = None
|
|
672
|
+
entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
673
|
+
return False
|
|
674
|
+
if not isinstance(env, dict):
|
|
675
|
+
raise ValueError(f"Expected {path} env to be a JSON object")
|
|
676
|
+
|
|
677
|
+
previous = entry.setdefault("previous_values", {})
|
|
678
|
+
changed = False
|
|
679
|
+
|
|
680
|
+
for key in ("ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
|
|
681
|
+
prior = previous.get(key, None)
|
|
682
|
+
if prior is None:
|
|
683
|
+
if key in env:
|
|
684
|
+
env.pop(key, None)
|
|
685
|
+
changed = True
|
|
686
|
+
elif env.get(key) != prior:
|
|
687
|
+
env[key] = prior
|
|
688
|
+
changed = True
|
|
689
|
+
|
|
690
|
+
if not env:
|
|
691
|
+
data.pop("env", None)
|
|
692
|
+
|
|
693
|
+
entry["configured"] = False
|
|
694
|
+
entry["verified"] = True
|
|
695
|
+
entry["config_fingerprint"] = _fingerprint_secret(json.dumps(data, sort_keys=True))
|
|
696
|
+
entry["matrx_key_fingerprint"] = None
|
|
697
|
+
entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
698
|
+
|
|
699
|
+
if changed:
|
|
700
|
+
_write_json_file(path, data)
|
|
701
|
+
return changed
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _sync_codex_route_config(state: dict, *, route: str) -> bool:
|
|
705
|
+
path = codex_config_path()
|
|
706
|
+
entry = _tool_config_entry(state, "codex")
|
|
707
|
+
prior_entry = dict(entry)
|
|
708
|
+
entry["config_path"] = str(path)
|
|
709
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
710
|
+
managed_block, content = _strip_codex_managed_block(text)
|
|
711
|
+
new_text = text
|
|
712
|
+
|
|
713
|
+
if managed_block is not None:
|
|
714
|
+
new_text = _restore_codex_config_text(entry, content)
|
|
715
|
+
|
|
716
|
+
changed = _normalize_toml_text(new_text) != _normalize_toml_text(text)
|
|
717
|
+
|
|
718
|
+
entry["configured"] = False
|
|
719
|
+
entry["verified"] = True
|
|
720
|
+
normalized_text = _normalize_toml_text(new_text)
|
|
721
|
+
entry["config_fingerprint"] = _fingerprint_secret(normalized_text) if normalized_text else None
|
|
722
|
+
entry["matrx_key_fingerprint"] = None
|
|
723
|
+
entry["last_verified_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
724
|
+
entry["previous_model_provider"] = None
|
|
725
|
+
entry["previous_matrx_block"] = None
|
|
726
|
+
|
|
727
|
+
if changed:
|
|
728
|
+
_write_or_remove_codex_config(path, new_text)
|
|
729
|
+
|
|
730
|
+
return changed or entry != prior_entry
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _strip_codex_managed_block(text: str) -> tuple[str | None, str]:
|
|
734
|
+
pattern = rf"(?ms)^\s*{re.escape(MTRX_CODEX_BLOCK_START)}\r?\n.*?^\s*{re.escape(MTRX_CODEX_BLOCK_END)}\s*$\r?\n?"
|
|
735
|
+
match = re.search(pattern, text)
|
|
736
|
+
if not match:
|
|
737
|
+
return None, text.strip()
|
|
738
|
+
without_managed = text[:match.start()] + text[match.end():]
|
|
739
|
+
return match.group(0).strip(), without_managed.strip()
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _restore_codex_config_text(entry: dict, content: str) -> str:
|
|
743
|
+
original_backup_path = (entry.get("original_backup_path") or "").strip()
|
|
744
|
+
if original_backup_path:
|
|
745
|
+
backup_path = Path(original_backup_path)
|
|
746
|
+
if backup_path.exists():
|
|
747
|
+
return backup_path.read_text(encoding="utf-8")
|
|
748
|
+
|
|
749
|
+
restored = content.rstrip()
|
|
750
|
+
prior_model_provider = (entry.get("previous_model_provider") or "").strip()
|
|
751
|
+
prior_matrx_block = (entry.get("previous_matrx_block") or "").strip()
|
|
752
|
+
|
|
753
|
+
if prior_model_provider and not re.search(r"(?m)^model_provider\s*=", restored):
|
|
754
|
+
restored = _append_toml_block(restored, prior_model_provider)
|
|
755
|
+
if prior_matrx_block and "[model_providers.matrx]" not in restored:
|
|
756
|
+
restored = _append_toml_block(restored, prior_matrx_block)
|
|
757
|
+
|
|
758
|
+
return restored
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _write_or_remove_codex_config(path: Path, text: str) -> None:
|
|
762
|
+
normalized = _normalize_toml_text(text)
|
|
763
|
+
if normalized:
|
|
764
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
765
|
+
path.write_text(normalized, encoding="utf-8")
|
|
766
|
+
_verify_codex_config_file(path)
|
|
767
|
+
return
|
|
768
|
+
if path.exists():
|
|
769
|
+
path.unlink()
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _append_toml_block(content: str, block: str) -> str:
|
|
773
|
+
base = content.rstrip()
|
|
774
|
+
addition = block.strip()
|
|
775
|
+
if not addition:
|
|
776
|
+
return base
|
|
777
|
+
if not base:
|
|
778
|
+
return addition
|
|
779
|
+
return f"{base}\n\n{addition}"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _normalize_toml_text(text: str) -> str:
|
|
783
|
+
cleaned = text.strip()
|
|
784
|
+
if not cleaned:
|
|
785
|
+
return ""
|
|
786
|
+
return cleaned + "\n"
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _verify_codex_config_file(path: Path) -> None:
|
|
790
|
+
if tomllib is None:
|
|
791
|
+
return
|
|
792
|
+
text = path.read_text(encoding="utf-8")
|
|
793
|
+
try:
|
|
794
|
+
tomllib.loads(text)
|
|
795
|
+
except tomllib.TOMLDecodeError as exc:
|
|
796
|
+
raise ValueError(f"Failed to parse Codex config at {path}") from exc
|