coderouter-cli 2.13.0__py3-none-any.whl → 2.14.0__py3-none-any.whl
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.
- coderouter/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +144 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +21 -17
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Credential resolution, including CLI-session borrowing (v2.14.0).
|
|
2
|
+
|
|
3
|
+
Until now a provider's credential could only be an env var. That is fine
|
|
4
|
+
for API keys and useless for the thing an increasing number of vendors
|
|
5
|
+
actually sell: a subscription, authenticated by an OAuth token that their
|
|
6
|
+
own CLI already fetched and wrote to disk.
|
|
7
|
+
|
|
8
|
+
CodeRouter's existing answer to those was ``kind: agent_cli`` — spawn the
|
|
9
|
+
vendor's CLI once per request and read its stdout. It works, and it
|
|
10
|
+
throws away almost everything CodeRouter is for. A one-shot ``exec`` has
|
|
11
|
+
no streaming, no tool-call repair, no context-budget guard, and it does
|
|
12
|
+
not sit in a fallback chain the way an HTTP provider does. The vendor's
|
|
13
|
+
subscription ends up as an island.
|
|
14
|
+
|
|
15
|
+
The alternative, which is what codex-router does for Kimi and Grok, is to
|
|
16
|
+
skip the process entirely: **read the token the CLI already wrote, and
|
|
17
|
+
make the HTTP call ourselves.** The provider then becomes an ordinary
|
|
18
|
+
``openai_compat`` entry, and every routing feature applies to it —
|
|
19
|
+
"use the subscription until it 429s, then fall through to a free cloud
|
|
20
|
+
model, then to local llama.cpp" becomes an ordinary chain.
|
|
21
|
+
|
|
22
|
+
- name: kimi-sub
|
|
23
|
+
kind: openai_compat
|
|
24
|
+
base_url: https://api.moonshot.cn/v1
|
|
25
|
+
credential:
|
|
26
|
+
source: cli_session
|
|
27
|
+
path: ~/.kimi-code/credentials/kimi-code.json
|
|
28
|
+
field: access_token
|
|
29
|
+
expiry_field: expires_at
|
|
30
|
+
refresh:
|
|
31
|
+
command: ["kimi", "auth", "status"]
|
|
32
|
+
|
|
33
|
+
Refresh: delegate, don't reimplement
|
|
34
|
+
------------------------------------
|
|
35
|
+
Refreshing is the part that rots. Every vendor has its own endpoint,
|
|
36
|
+
client id, rotation policy and error shape, and all of them change. So
|
|
37
|
+
this module does not implement OAuth. It runs the vendor's own CLI and
|
|
38
|
+
re-reads the file — codex-router's approach for Grok, and the reason that
|
|
39
|
+
path has survived contact with a moving vendor while its bespoke Kimi
|
|
40
|
+
refresh has not.
|
|
41
|
+
|
|
42
|
+
The command is an argv **list**, dispatched with ``shell=False``. That is
|
|
43
|
+
the same trust decision v2.13.0 made for ``restart_command``: a string
|
|
44
|
+
that goes through a shell turns a config file into arbitrary code
|
|
45
|
+
execution, and half-executing one (``touch a; b`` under ``shell=False``
|
|
46
|
+
creates a file literally named ``a;`` and reports success) is worse than
|
|
47
|
+
refusing. Here there is no string form to refuse — the schema only
|
|
48
|
+
accepts a list.
|
|
49
|
+
|
|
50
|
+
Concurrency
|
|
51
|
+
-----------
|
|
52
|
+
Two workers hitting an expired token would otherwise both shell out.
|
|
53
|
+
Refresh is single-flighted in-process by a per-path lock, and serialised
|
|
54
|
+
across processes by an advisory ``flock`` on a sidecar file where the
|
|
55
|
+
platform has one. After acquiring either lock the file is re-read before
|
|
56
|
+
deciding to refresh, because the holder of the lock may already have done
|
|
57
|
+
the work.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import subprocess
|
|
65
|
+
import threading
|
|
66
|
+
import time
|
|
67
|
+
from dataclasses import dataclass
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from typing import Any
|
|
70
|
+
|
|
71
|
+
from coderouter.logging import get_logger
|
|
72
|
+
from coderouter.secret_redaction import register_secret
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"CredentialError",
|
|
76
|
+
"resolve_provider_credential",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
logger = get_logger(__name__)
|
|
80
|
+
|
|
81
|
+
# One lock per session file, created on demand. Keyed by the resolved
|
|
82
|
+
# absolute path so two providers pointing at the same file share it.
|
|
83
|
+
_locks: dict[str, threading.Lock] = {}
|
|
84
|
+
_locks_guard = threading.Lock()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CredentialError(RuntimeError):
|
|
88
|
+
"""A credential was configured but could not be produced."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _SessionRead:
|
|
93
|
+
token: str | None
|
|
94
|
+
expires_at: float | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lock_for(path: str) -> threading.Lock:
|
|
98
|
+
with _locks_guard:
|
|
99
|
+
return _locks.setdefault(path, threading.Lock())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dotted_get(payload: Any, dotted: str) -> Any:
|
|
103
|
+
"""Fetch ``a.b.c`` out of nested mappings.
|
|
104
|
+
|
|
105
|
+
Vendors nest differently (``access_token`` at the root for Kimi, under
|
|
106
|
+
``tokens`` for others), so the field is a path rather than a key. A
|
|
107
|
+
missing segment returns ``None`` rather than raising — an absent token
|
|
108
|
+
is a normal state (the operator has not logged in yet) and the caller
|
|
109
|
+
turns it into a clear message.
|
|
110
|
+
"""
|
|
111
|
+
current = payload
|
|
112
|
+
for segment in dotted.split("."):
|
|
113
|
+
if not isinstance(current, dict) or segment not in current:
|
|
114
|
+
return None
|
|
115
|
+
current = current[segment]
|
|
116
|
+
return current
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _as_epoch_seconds(value: Any) -> float | None:
|
|
120
|
+
"""Normalise an expiry field to epoch seconds.
|
|
121
|
+
|
|
122
|
+
Accepts seconds and milliseconds, because both are common and telling
|
|
123
|
+
them apart is unambiguous: any plausible expiry in seconds is under
|
|
124
|
+
1e11, and any in milliseconds is over it. A value we cannot read at
|
|
125
|
+
all yields ``None``, which is treated as "no expiry information" —
|
|
126
|
+
the token is used and the upstream 401 becomes the signal instead.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(value, bool) or value is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(value, (int, float)):
|
|
131
|
+
seconds = float(value)
|
|
132
|
+
return seconds / 1000.0 if seconds > 1e11 else seconds
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
try:
|
|
135
|
+
return _as_epoch_seconds(float(value))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read_session(path: Path, *, field: str, expiry_field: str) -> _SessionRead:
|
|
142
|
+
"""Read the token (and its expiry, if present) out of a session file."""
|
|
143
|
+
try:
|
|
144
|
+
raw = path.read_text(encoding="utf-8")
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise CredentialError(f"cannot read session file {path}: {exc}") from exc
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(raw)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
raise CredentialError(f"session file {path} is not valid JSON: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
token = _dotted_get(payload, field)
|
|
153
|
+
if token is not None and not isinstance(token, str):
|
|
154
|
+
raise CredentialError(
|
|
155
|
+
f"session file {path}: field {field!r} is "
|
|
156
|
+
f"{type(token).__name__}, expected a string"
|
|
157
|
+
)
|
|
158
|
+
expires_at = _as_epoch_seconds(_dotted_get(payload, expiry_field))
|
|
159
|
+
return _SessionRead(token=token or None, expires_at=expires_at)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _needs_refresh(read: _SessionRead, *, early_ratio: float, min_lead_s: float) -> bool:
|
|
163
|
+
"""Whether to refresh now.
|
|
164
|
+
|
|
165
|
+
Refresh early rather than on expiry: a token that dies mid-request
|
|
166
|
+
costs a retry and a confusing upstream error. ``early_ratio`` is a
|
|
167
|
+
fraction of the *remaining* lifetime as seen right now, floored by
|
|
168
|
+
``min_lead_s`` — so a long-lived token still refreshes several minutes
|
|
169
|
+
before it lapses rather than at the last second.
|
|
170
|
+
"""
|
|
171
|
+
if read.token is None:
|
|
172
|
+
return True
|
|
173
|
+
if read.expires_at is None:
|
|
174
|
+
return False
|
|
175
|
+
remaining = read.expires_at - time.time()
|
|
176
|
+
return remaining <= max(min_lead_s, remaining * early_ratio)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_refresh(command: list[str], *, timeout_s: float, path: Path) -> None:
|
|
180
|
+
"""Run the vendor CLI so it rotates the token, then let the caller re-read.
|
|
181
|
+
|
|
182
|
+
``shell=False`` with an argv list: the config file names a program, it
|
|
183
|
+
does not get to compose a shell command. Failures are logged and
|
|
184
|
+
swallowed — a stale-but-present token is still worth trying, and the
|
|
185
|
+
upstream 401 is a better error for the operator than a refresh
|
|
186
|
+
traceback that hides it.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
completed = subprocess.run(
|
|
190
|
+
command,
|
|
191
|
+
shell=False,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
timeout=timeout_s,
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"credential-refresh-failed",
|
|
199
|
+
extra={"path": str(path), "command": command[0], "error": str(exc)},
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
if completed.returncode != 0:
|
|
203
|
+
# stderr is deliberately NOT logged: a failing auth CLI is exactly
|
|
204
|
+
# the thing most likely to print a token or a device code.
|
|
205
|
+
logger.warning(
|
|
206
|
+
"credential-refresh-nonzero",
|
|
207
|
+
extra={
|
|
208
|
+
"path": str(path),
|
|
209
|
+
"command": command[0],
|
|
210
|
+
"returncode": completed.returncode,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _flock(handle: Any, *, exclusive: bool = True) -> Any:
|
|
216
|
+
"""Best-effort advisory lock; a no-op where the platform has none."""
|
|
217
|
+
try:
|
|
218
|
+
import fcntl
|
|
219
|
+
except ImportError: # pragma: no cover - Windows
|
|
220
|
+
return None
|
|
221
|
+
fcntl.flock(handle, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
|
222
|
+
return fcntl
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_cli_session(spec: Any, provider_name: str) -> str | None:
|
|
226
|
+
"""Read (and if needed refresh) a token a vendor CLI wrote to disk."""
|
|
227
|
+
path = Path(str(spec.path)).expanduser()
|
|
228
|
+
field = spec.field
|
|
229
|
+
expiry_field = spec.expiry_field
|
|
230
|
+
|
|
231
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
232
|
+
refresh = spec.refresh
|
|
233
|
+
if refresh is None or not _needs_refresh(
|
|
234
|
+
read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
|
|
235
|
+
):
|
|
236
|
+
return _register(read.token, provider_name)
|
|
237
|
+
|
|
238
|
+
with _lock_for(str(path)):
|
|
239
|
+
# Re-read under the in-process lock: another worker may have
|
|
240
|
+
# refreshed while we waited.
|
|
241
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
242
|
+
if not _needs_refresh(
|
|
243
|
+
read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
|
|
244
|
+
):
|
|
245
|
+
return _register(read.token, provider_name)
|
|
246
|
+
|
|
247
|
+
lock_path = path.with_name(path.name + ".coderouter-lock")
|
|
248
|
+
try:
|
|
249
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
with open(lock_path, "a+") as handle:
|
|
251
|
+
_flock(handle)
|
|
252
|
+
# And once more across processes, same reason.
|
|
253
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
254
|
+
if _needs_refresh(
|
|
255
|
+
read,
|
|
256
|
+
early_ratio=refresh.early_ratio,
|
|
257
|
+
min_lead_s=refresh.min_lead_s,
|
|
258
|
+
):
|
|
259
|
+
logger.info(
|
|
260
|
+
"credential-refresh-started",
|
|
261
|
+
extra={"provider": provider_name, "command": refresh.command[0]},
|
|
262
|
+
)
|
|
263
|
+
_run_refresh(
|
|
264
|
+
list(refresh.command), timeout_s=refresh.timeout_s, path=path
|
|
265
|
+
)
|
|
266
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
logger.warning(
|
|
269
|
+
"credential-lock-failed",
|
|
270
|
+
extra={"path": str(lock_path), "error": str(exc)},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return _register(read.token, provider_name)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _register(token: str | None, provider_name: str) -> str | None:
|
|
277
|
+
"""Arm the log scrubber before the token goes anywhere near a header."""
|
|
278
|
+
if token:
|
|
279
|
+
register_secret(token, f"{provider_name}-session")
|
|
280
|
+
return token
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def resolve_provider_credential(config: Any) -> str | None:
|
|
284
|
+
"""Return the credential for ``config``, or ``None`` when there is none.
|
|
285
|
+
|
|
286
|
+
Order of precedence is deliberately not "try everything":
|
|
287
|
+
``credential`` and ``api_key_env`` are mutually exclusive at the
|
|
288
|
+
schema level, so exactly one path can apply. A resolver that silently
|
|
289
|
+
fell back between sources would make "which credential is this request
|
|
290
|
+
actually using?" unanswerable, which is the question an operator asks
|
|
291
|
+
at exactly the wrong moment.
|
|
292
|
+
"""
|
|
293
|
+
from coderouter.config.loader import resolve_api_key
|
|
294
|
+
|
|
295
|
+
spec = getattr(config, "credential", None)
|
|
296
|
+
if spec is None:
|
|
297
|
+
return resolve_api_key(getattr(config, "api_key_env", None))
|
|
298
|
+
|
|
299
|
+
name = str(getattr(config, "name", "?"))
|
|
300
|
+
if spec.source == "env":
|
|
301
|
+
return resolve_api_key(spec.env)
|
|
302
|
+
if spec.source == "cli_session":
|
|
303
|
+
try:
|
|
304
|
+
return _resolve_cli_session(spec, name)
|
|
305
|
+
except CredentialError as exc:
|
|
306
|
+
# A missing or malformed session file is an operator problem
|
|
307
|
+
# (not logged in yet, vendor changed its layout), not a crash.
|
|
308
|
+
# Returning None sends the request unauthenticated, the upstream
|
|
309
|
+
# 401 fires, and the fallback chain moves on.
|
|
310
|
+
logger.warning(
|
|
311
|
+
"credential-unavailable",
|
|
312
|
+
extra={"provider": name, "source": spec.source, "error": str(exc)},
|
|
313
|
+
)
|
|
314
|
+
return None
|
|
315
|
+
raise CredentialError(f"provider {name}: unknown credential source {spec.source!r}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def session_path_is_sane(path: str) -> bool:
|
|
319
|
+
"""Reject a session path that reaches outside the user's home.
|
|
320
|
+
|
|
321
|
+
``providers.yaml`` is operator-owned config, at the same trust level as
|
|
322
|
+
``launcher.backends[*].binary`` — but v2.13.0 exists because that
|
|
323
|
+
assumption broke once already when a file could be picked up from the
|
|
324
|
+
working directory. A vendor CLI writes under ``$HOME``; nothing
|
|
325
|
+
legitimate needs ``/etc/shadow``.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
resolved = Path(path).expanduser().resolve()
|
|
329
|
+
home = Path.home().resolve()
|
|
330
|
+
except (OSError, RuntimeError):
|
|
331
|
+
return False
|
|
332
|
+
return resolved == home or home in resolved.parents or os.name == "nt"
|
coderouter/doctor_apply.py
CHANGED
|
@@ -520,6 +520,34 @@ def _resolve_target_path(
|
|
|
520
520
|
return None
|
|
521
521
|
|
|
522
522
|
|
|
523
|
+
def _undo_partial_apply(
|
|
524
|
+
backups: dict[str, str], created_paths: list[Path]
|
|
525
|
+
) -> int:
|
|
526
|
+
"""Put back every file this invocation already rewrote.
|
|
527
|
+
|
|
528
|
+
Best-effort by design: a rollback that raises would mask the original
|
|
529
|
+
write failure, which is the thing the operator actually needs to see.
|
|
530
|
+
Files we created from nothing are removed; files we overwrote are
|
|
531
|
+
restored from the ``.bak`` copy taken moments earlier in the same loop.
|
|
532
|
+
|
|
533
|
+
Returns the number of files successfully reverted, for the error text.
|
|
534
|
+
"""
|
|
535
|
+
reverted = 0
|
|
536
|
+
for target_str, backup_str in backups.items():
|
|
537
|
+
try:
|
|
538
|
+
shutil.copy2(backup_str, target_str)
|
|
539
|
+
reverted += 1
|
|
540
|
+
except OSError:
|
|
541
|
+
continue
|
|
542
|
+
for created in created_paths:
|
|
543
|
+
try:
|
|
544
|
+
created.unlink(missing_ok=True)
|
|
545
|
+
reverted += 1
|
|
546
|
+
except OSError:
|
|
547
|
+
continue
|
|
548
|
+
return reverted
|
|
549
|
+
|
|
550
|
+
|
|
523
551
|
def apply_doctor_patches(
|
|
524
552
|
*,
|
|
525
553
|
report: Any, # DoctorReport — typed Any to avoid an import cycle
|
|
@@ -637,6 +665,9 @@ def apply_doctor_patches(
|
|
|
637
665
|
written = False
|
|
638
666
|
written_paths: list[str] = []
|
|
639
667
|
backups: dict[str, str] = {}
|
|
668
|
+
# Files this invocation created from nothing. They have no backup to
|
|
669
|
+
# restore from, so undoing them means deleting them again.
|
|
670
|
+
created_paths: list[Path] = []
|
|
640
671
|
for target_path in target_order:
|
|
641
672
|
doc, before_text = docs_by_path[target_path]
|
|
642
673
|
after_text = _dump_yaml_with_comments(doc)
|
|
@@ -656,15 +687,29 @@ def apply_doctor_patches(
|
|
|
656
687
|
reformat_only[str(target_path)] = diff_text
|
|
657
688
|
if write and target_changed:
|
|
658
689
|
# Backup first (only when we have a pre-existing file).
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
target_path.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
690
|
+
try:
|
|
691
|
+
if target_path.is_file():
|
|
692
|
+
backup_path = target_path.with_suffix(
|
|
693
|
+
target_path.suffix + PROVIDERS_BACKUP_SUFFIX
|
|
694
|
+
)
|
|
695
|
+
shutil.copy2(target_path, backup_path)
|
|
696
|
+
backups[str(target_path)] = str(backup_path)
|
|
697
|
+
else:
|
|
698
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
created_paths.append(target_path)
|
|
700
|
+
target_path.write_text(after_text, encoding="utf-8")
|
|
701
|
+
except OSError as exc:
|
|
702
|
+
# v2.14.0: an apply that spans two files used to leave the
|
|
703
|
+
# first one rewritten when the second failed — a half-applied
|
|
704
|
+
# config the operator had no way to name, let alone undo. Roll
|
|
705
|
+
# the completed writes back before surfacing the error, so a
|
|
706
|
+
# failed apply is always a no-op rather than a partial one.
|
|
707
|
+
undone = _undo_partial_apply(backups, created_paths)
|
|
708
|
+
raise DoctorApplyError(
|
|
709
|
+
f"failed to write {target_path}: {exc}. "
|
|
710
|
+
f"Rolled back {undone} already-written file(s); "
|
|
711
|
+
"no partial apply was left on disk."
|
|
712
|
+
) from exc
|
|
668
713
|
written = True
|
|
669
714
|
written_paths.append(str(target_path))
|
|
670
715
|
|
|
@@ -34,9 +34,11 @@ Why inline JS / CSS
|
|
|
34
34
|
|
|
35
35
|
from __future__ import annotations
|
|
36
36
|
|
|
37
|
-
from fastapi import APIRouter
|
|
37
|
+
from fastapi import APIRouter, Request
|
|
38
38
|
from fastapi.responses import HTMLResponse
|
|
39
39
|
|
|
40
|
+
from coderouter.ingress.ui_auth import auth_required, require_ui_token
|
|
41
|
+
|
|
40
42
|
router = APIRouter()
|
|
41
43
|
|
|
42
44
|
|
|
@@ -219,6 +221,17 @@ _DASHBOARD_HTML = r"""<!doctype html>
|
|
|
219
221
|
// Poll interval — 2s matches plan.md §12.3.5. Short enough to feel
|
|
220
222
|
// live on a local dev machine, long enough that HTTP overhead is noise.
|
|
221
223
|
const POLL_MS = 2000;
|
|
224
|
+
// v2.14.0: the token is entered by the operator and kept in
|
|
225
|
+
// sessionStorage (cleared when the tab closes). The page is told only
|
|
226
|
+
// whether auth is on, never what the token is.
|
|
227
|
+
const AUTH_REQUIRED = __METRICS_AUTH_REQUIRED__;
|
|
228
|
+
const TOKEN_KEY = "coderouter-metrics-token";
|
|
229
|
+
let metricsToken = sessionStorage.getItem(TOKEN_KEY) || "";
|
|
230
|
+
if (AUTH_REQUIRED && !metricsToken) {
|
|
231
|
+
const v = window.prompt("CODEROUTER_METRICS_TOKEN を入力してください", "");
|
|
232
|
+
if (v) { metricsToken = v.trim(); sessionStorage.setItem(TOKEN_KEY, metricsToken); }
|
|
233
|
+
}
|
|
234
|
+
const authHeaders = () => metricsToken ? {"X-CodeRouter-Token": metricsToken} : {};
|
|
222
235
|
// Sparkline ring — 60 samples at 2s/sample → 2 minutes of history.
|
|
223
236
|
const SPARK_POINTS = 60;
|
|
224
237
|
const sparkBuffer = [];
|
|
@@ -524,7 +537,7 @@ _DASHBOARD_HTML = r"""<!doctype html>
|
|
|
524
537
|
|
|
525
538
|
const poll = async () => {
|
|
526
539
|
try {
|
|
527
|
-
const resp = await fetch("/metrics.json", {cache: "no-store"});
|
|
540
|
+
const resp = await fetch("/metrics.json", {cache: "no-store", headers: authHeaders()});
|
|
528
541
|
if (!resp.ok) {
|
|
529
542
|
renderError("HTTP " + resp.status);
|
|
530
543
|
return;
|
|
@@ -547,7 +560,7 @@ _DASHBOARD_HTML = r"""<!doctype html>
|
|
|
547
560
|
|
|
548
561
|
|
|
549
562
|
@router.get("/dashboard", response_class=HTMLResponse)
|
|
550
|
-
async def dashboard() -> HTMLResponse:
|
|
563
|
+
async def dashboard(request: Request) -> HTMLResponse:
|
|
551
564
|
"""Serve the single-page dashboard HTML.
|
|
552
565
|
|
|
553
566
|
The page is entirely static — all data comes from polling
|
|
@@ -556,4 +569,11 @@ async def dashboard() -> HTMLResponse:
|
|
|
556
569
|
the template lives in this module as a string, keeping the shipping
|
|
557
570
|
unit a single Python file with no template dir to configure.
|
|
558
571
|
"""
|
|
559
|
-
|
|
572
|
+
require_ui_token(request)
|
|
573
|
+
# Only the boolean reaches the page — never the token. Same split the
|
|
574
|
+
# v2.13.0 launcher fix introduced after ``curl /launcher | grep``
|
|
575
|
+
# recovered the shared secret straight out of the HTML.
|
|
576
|
+
html = _DASHBOARD_HTML.replace(
|
|
577
|
+
"__METRICS_AUTH_REQUIRED__", "true" if auth_required() else "false"
|
|
578
|
+
)
|
|
579
|
+
return HTMLResponse(content=html)
|
|
@@ -28,7 +28,9 @@ from typing import Any
|
|
|
28
28
|
from fastapi import APIRouter, Request
|
|
29
29
|
from fastapi.responses import PlainTextResponse
|
|
30
30
|
|
|
31
|
+
from coderouter.ingress.ui_auth import require_ui_token
|
|
31
32
|
from coderouter.metrics import format_prometheus, get_collector
|
|
33
|
+
from coderouter.secret_redaction import redact
|
|
32
34
|
|
|
33
35
|
router = APIRouter()
|
|
34
36
|
|
|
@@ -48,6 +50,11 @@ async def metrics_json(request: Request) -> dict[str, Any]:
|
|
|
48
50
|
dashboard uses it to classify providers into local / free / paid
|
|
49
51
|
for the usage-mix bar without a second endpoint round-trip.
|
|
50
52
|
"""
|
|
53
|
+
# v2.14.0: opt-in token first, before anything is computed. The
|
|
54
|
+
# ordering is the point — codex-router's /health answers before its
|
|
55
|
+
# own auth check and leaks the live session name to any local caller.
|
|
56
|
+
require_ui_token(request)
|
|
57
|
+
|
|
51
58
|
snapshot = get_collector().snapshot()
|
|
52
59
|
|
|
53
60
|
config = getattr(request.app.state, "config", None)
|
|
@@ -66,7 +73,10 @@ async def metrics_json(request: Request) -> dict[str, Any]:
|
|
|
66
73
|
"paid": p.paid,
|
|
67
74
|
# ``HttpUrl`` is not JSON-serializable directly in Pydantic v2;
|
|
68
75
|
# the cast also makes the shape stable if Pydantic switches types.
|
|
69
|
-
|
|
76
|
+
# v2.14.0: run it through the scrubber. A base_url is the
|
|
77
|
+
# one config field an operator can paste a credential into
|
|
78
|
+
# (``?api_key=…``), and this endpoint hands it to a browser.
|
|
79
|
+
"base_url": redact(str(p.base_url)),
|
|
70
80
|
}
|
|
71
81
|
for p in config.providers
|
|
72
82
|
],
|
|
@@ -79,7 +89,7 @@ async def metrics_json(request: Request) -> dict[str, Any]:
|
|
|
79
89
|
|
|
80
90
|
|
|
81
91
|
@router.get("/metrics", response_class=PlainTextResponse)
|
|
82
|
-
async def metrics_prometheus() -> PlainTextResponse:
|
|
92
|
+
async def metrics_prometheus(request: Request) -> PlainTextResponse:
|
|
83
93
|
"""Prometheus text exposition format (v1.5-B).
|
|
84
94
|
|
|
85
95
|
Convention-compliant endpoint path for Prometheus scrapers. Returns
|
|
@@ -88,5 +98,6 @@ async def metrics_prometheus() -> PlainTextResponse:
|
|
|
88
98
|
Sits alongside :func:`metrics_json` (not a replacement) — JSON is
|
|
89
99
|
for internal UI, Prometheus is for external time-series DBs.
|
|
90
100
|
"""
|
|
101
|
+
require_ui_token(request)
|
|
91
102
|
body = format_prometheus(get_collector().snapshot())
|
|
92
103
|
return PlainTextResponse(content=body, media_type=_PROM_CONTENT_TYPE)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Opt-in token auth for the read-only UI surfaces (v2.14.0).
|
|
2
|
+
|
|
3
|
+
``/dashboard``, ``/metrics.json`` and ``/metrics`` were the last endpoints
|
|
4
|
+
with no authentication story at all. They change nothing, so they were
|
|
5
|
+
never part of the H-8 state-changing-endpoint work — but "read-only" is
|
|
6
|
+
not "harmless". ``/metrics.json`` returns every provider's name, kind,
|
|
7
|
+
paid flag and ``base_url``, plus the profile graph: the full topology of
|
|
8
|
+
which models an operator runs and which vendors they pay. On a laptop
|
|
9
|
+
that is fine. On a box where the port is reachable by anything else, it
|
|
10
|
+
is a free reconnaissance endpoint.
|
|
11
|
+
|
|
12
|
+
The failure we are deliberately not repeating is codex-router's: its
|
|
13
|
+
``/health`` answers *before* the caller-key check and includes the live
|
|
14
|
+
session name, so any local process can read what the operator is working
|
|
15
|
+
on. Cheap to avoid, easy to ship by accident — the check just has to come
|
|
16
|
+
first.
|
|
17
|
+
|
|
18
|
+
Contract
|
|
19
|
+
--------
|
|
20
|
+
Identical in shape to :func:`coderouter.ingress.launcher_routes._require_launcher_token`,
|
|
21
|
+
so an operator learns it once:
|
|
22
|
+
|
|
23
|
+
* ``CODEROUTER_METRICS_TOKEN`` unset → the endpoints stay open exactly as
|
|
24
|
+
they were, and a one-time warning is logged. Backwards compatible by
|
|
25
|
+
construction; nobody's Prometheus scrape breaks on upgrade.
|
|
26
|
+
* Set → every request must carry a matching ``X-CodeRouter-Token`` header,
|
|
27
|
+
compared with :func:`secrets.compare_digest`.
|
|
28
|
+
|
|
29
|
+
The token is deliberately **not** accepted as a query parameter. A token
|
|
30
|
+
in a URL lands in access logs, in ``Referer`` headers, and in the browser
|
|
31
|
+
history — and this codebase now has a log scrubber precisely because
|
|
32
|
+
credentials end up in places nobody predicted.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
import secrets
|
|
39
|
+
|
|
40
|
+
from fastapi import HTTPException, Request
|
|
41
|
+
|
|
42
|
+
from coderouter.logging import get_logger
|
|
43
|
+
|
|
44
|
+
__all__ = ["METRICS_TOKEN_ENV", "METRICS_TOKEN_HEADER", "require_ui_token"]
|
|
45
|
+
|
|
46
|
+
logger = get_logger(__name__)
|
|
47
|
+
|
|
48
|
+
METRICS_TOKEN_ENV = "CODEROUTER_METRICS_TOKEN"
|
|
49
|
+
METRICS_TOKEN_HEADER = "X-CodeRouter-Token"
|
|
50
|
+
|
|
51
|
+
# One warning per process, not per request — a 2-second dashboard poll
|
|
52
|
+
# would otherwise bury every other log line.
|
|
53
|
+
_warning_emitted = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def require_ui_token(request: Request) -> None:
|
|
57
|
+
"""Enforce the metrics/dashboard token when one is configured.
|
|
58
|
+
|
|
59
|
+
Raises ``401`` on mismatch. Returns silently when the env var is
|
|
60
|
+
unset, after logging a single ``metrics-auth-disabled`` line.
|
|
61
|
+
"""
|
|
62
|
+
global _warning_emitted
|
|
63
|
+
expected = os.environ.get(METRICS_TOKEN_ENV, "")
|
|
64
|
+
if not expected:
|
|
65
|
+
if not _warning_emitted:
|
|
66
|
+
logger.warning(
|
|
67
|
+
"metrics-auth-disabled",
|
|
68
|
+
extra={
|
|
69
|
+
"hint": (
|
|
70
|
+
f"{METRICS_TOKEN_ENV} is not set; /dashboard, "
|
|
71
|
+
"/metrics.json and /metrics are unauthenticated and "
|
|
72
|
+
"expose provider names, base_urls and the profile "
|
|
73
|
+
"graph. Set it when binding to anything but loopback."
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
_warning_emitted = True
|
|
78
|
+
return
|
|
79
|
+
provided = request.headers.get(METRICS_TOKEN_HEADER, "")
|
|
80
|
+
if not secrets.compare_digest(provided, expected):
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=401, detail="Invalid or missing metrics token."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def auth_required() -> bool:
|
|
87
|
+
"""Whether a token is configured — safe to embed in the page.
|
|
88
|
+
|
|
89
|
+
The dashboard needs to know *that* auth is on so it can prompt; it
|
|
90
|
+
must never receive the token itself. Same split as the v2.13.0 fix
|
|
91
|
+
that stopped ``GET /launcher`` embedding ``CODEROUTER_LAUNCHER_TOKEN``
|
|
92
|
+
straight into the HTML, where ``curl | grep`` recovered it.
|
|
93
|
+
"""
|
|
94
|
+
return bool(os.environ.get(METRICS_TOKEN_ENV, ""))
|
coderouter/logging.py
CHANGED
|
@@ -23,6 +23,8 @@ import logging
|
|
|
23
23
|
import sys
|
|
24
24
|
from typing import Any, Literal, TypedDict
|
|
25
25
|
|
|
26
|
+
from coderouter.secret_redaction import install_secret_filter
|
|
27
|
+
|
|
26
28
|
|
|
27
29
|
class JsonLineFormatter(logging.Formatter):
|
|
28
30
|
"""Emit each record as a single JSON line."""
|
|
@@ -105,6 +107,11 @@ def configure_logging(level: str = "INFO") -> None:
|
|
|
105
107
|
root.removeHandler(h)
|
|
106
108
|
handler = logging.StreamHandler(sys.stderr)
|
|
107
109
|
handler.setFormatter(JsonLineFormatter())
|
|
110
|
+
# v2.14.0: scrub registered credentials before the formatter runs. The
|
|
111
|
+
# filter is attached per-handler (not to the root logger) because logger
|
|
112
|
+
# filters do not apply to records propagated up from child loggers —
|
|
113
|
+
# every emitting module in this codebase uses its own module logger.
|
|
114
|
+
install_secret_filter(handler)
|
|
108
115
|
setattr(handler, _CODEROUTER_LOG_HANDLER_MARKER, True)
|
|
109
116
|
root.addHandler(handler)
|
|
110
117
|
|