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.
@@ -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"
@@ -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
- if target_path.is_file():
660
- backup_path = target_path.with_suffix(
661
- target_path.suffix + PROVIDERS_BACKUP_SUFFIX
662
- )
663
- shutil.copy2(target_path, backup_path)
664
- backups[str(target_path)] = str(backup_path)
665
- else:
666
- target_path.parent.mkdir(parents=True, exist_ok=True)
667
- target_path.write_text(after_text, encoding="utf-8")
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
- return HTMLResponse(content=_DASHBOARD_HTML)
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
- "base_url": str(p.base_url),
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