coderouter-cli 2.12.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.
@@ -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)
@@ -552,6 +552,47 @@ def _resolve_within_model_dirs(model_path: str, model_dirs: list[str]) -> Path:
552
552
  raise ValueError("model_path is not under any configured model_dirs.")
553
553
 
554
554
 
555
+ _model_dirs_warning_emitted = False
556
+
557
+
558
+ def _assert_model_path_allowed(
559
+ launcher_cfg: Any, model_path: str | None, *, field: str
560
+ ) -> None:
561
+ """model_path が launcher.model_dirs 配下か検査(パスは書き換えない)。
562
+
563
+ M-1: ``/api/launcher/start`` と ``/sweep/start`` はこれまで任意の
564
+ ファイルシステムパスを spawn に渡していた。ここで ``model_dirs`` 配下に
565
+ 限定する。ただし **検証のみ**で resolve 済みパスを spawn に渡さない
566
+ (macOS の /var→/private/var symlink で argv が変わり既存テストが落ちる)。
567
+ ``model_dirs`` 未設定なら従来どおり通し、一度だけ警告する。
568
+ """
569
+ global _model_dirs_warning_emitted
570
+ if not model_path:
571
+ return
572
+ model_dirs: list[str] = (
573
+ list(getattr(launcher_cfg, "model_dirs", []) or []) if launcher_cfg else []
574
+ )
575
+ if not model_dirs:
576
+ if not _model_dirs_warning_emitted:
577
+ logger.warning(
578
+ "launcher-model-dirs-unconfigured",
579
+ extra={
580
+ "hint": (
581
+ "launcher.model_dirs is empty; POST /api/launcher/start and "
582
+ "/sweep/start accept any filesystem path. Configure "
583
+ "launcher.model_dirs when the launcher is reachable from "
584
+ "anything but loopback."
585
+ )
586
+ },
587
+ )
588
+ _model_dirs_warning_emitted = True
589
+ return
590
+ try:
591
+ _resolve_within_model_dirs(model_path, model_dirs)
592
+ except ValueError as exc:
593
+ raise HTTPException(status_code=400, detail=f"{field}: {exc}") from exc
594
+
595
+
555
596
  def _option_tokens(options: dict[str, Any]) -> list[str]:
556
597
  """Flatten an ``options`` dict into CLI tokens (``{flag: val}`` semantics).
557
598
 
@@ -1410,6 +1451,8 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
1410
1451
  cfg = request.app.state.config
1411
1452
  launcher_cfg = getattr(cfg, "launcher", None)
1412
1453
  _assert_backend_declared(launcher_cfg, req.backend)
1454
+ _assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
1455
+ _assert_model_path_allowed(launcher_cfg, req.draft_model_path, field="draft_model_path")
1413
1456
  # デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
1414
1457
  # None を渡し、既存の argv と完全一致(後方互換)。
1415
1458
  device_args: list[str] | None = None
@@ -1665,8 +1708,10 @@ class SweepRequest(BaseModel):
1665
1708
  options: dict[str, Any] = Field(default_factory=dict)
1666
1709
  extra_args: str = ""
1667
1710
  configs: list[SweepConfigItem] = Field(default_factory=list)
1668
- bench_command: str | None = None # None なら launcher.bench 既定
1669
- runs: int | None = None
1711
+ bench_preset: str | None = None # None なら launcher.bench.default_preset / 暗黙 default
1712
+ runs: int | None = Field(default=None, ge=1, le=1000)
1713
+ # 廃止フィールド。黙って捨てると事故になるので api_sweep_start で 400 にする。
1714
+ bench_command: str | None = None
1670
1715
  results_dir: str | None = None
1671
1716
 
1672
1717
 
@@ -1872,25 +1917,73 @@ class _SweepRunner:
1872
1917
  await self._safe_stop(mp.id)
1873
1918
 
1874
1919
 
1875
- def _bench_defaults(launcher_cfg: Any) -> tuple[str, int, str | None, float]:
1876
- """launcher.bench(あれば)からベンチ既定を取り出す。無ければハードコード。"""
1877
- default_template = "llmbench run --model local-openai --runs {runs}"
1878
- default_runs = 5
1879
- default_results_dir: str | None = None
1880
- default_readiness = _DEFAULT_READINESS_TIMEOUT_S
1920
+ _HARDCODED_BENCH_TEMPLATE = "llmbench run --model local-openai --runs {runs}"
1921
+ _HARDCODED_BENCH_RUNS = 5
1922
+
1923
+
1924
+ @dataclass(frozen=True)
1925
+ class _ResolvedBench:
1926
+ key: str
1927
+ name: str
1928
+ command_template: str
1929
+ runs: int
1930
+ results_dir: str | None
1931
+ readiness_timeout_s: float
1932
+
1933
+
1934
+ def _bench_preset_map(launcher_cfg: Any) -> dict[str, Any]:
1881
1935
  bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
1882
- if bench_cfg is not None:
1883
- default_template = bench_cfg.command_template
1884
- default_runs = bench_cfg.runs
1885
- default_results_dir = bench_cfg.results_dir
1886
- default_readiness = bench_cfg.readiness_timeout_s
1887
- return default_template, default_runs, default_results_dir, default_readiness
1936
+ return dict(getattr(bench_cfg, "presets", {}) or {})
1937
+
1938
+
1939
+ def _resolve_bench(launcher_cfg: Any, preset_key: str | None) -> _ResolvedBench:
1940
+ """H-2: リクエストの bench_preset を config 上の実体へ解決。無いキーは ValueError(→400)。"""
1941
+ bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
1942
+ base_template = getattr(bench_cfg, "command_template", None) or _HARDCODED_BENCH_TEMPLATE
1943
+ base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
1944
+ base_results = getattr(bench_cfg, "results_dir", None)
1945
+ readiness = getattr(bench_cfg, "readiness_timeout_s", None) or _DEFAULT_READINESS_TIMEOUT_S
1946
+ presets = _bench_preset_map(launcher_cfg)
1947
+ key = preset_key or getattr(bench_cfg, "default_preset", None)
1948
+ if key is None or (key == "default" and "default" not in presets):
1949
+ return _ResolvedBench(
1950
+ key="default",
1951
+ name="default (launcher.bench.command_template)",
1952
+ command_template=base_template,
1953
+ runs=base_runs,
1954
+ results_dir=base_results,
1955
+ readiness_timeout_s=readiness,
1956
+ )
1957
+ preset = presets.get(key)
1958
+ if preset is None:
1959
+ known = sorted(presets) or ["default"]
1960
+ raise ValueError(
1961
+ f"Unknown bench preset {key!r}. Declare it under launcher.bench.presets "
1962
+ f"in providers.yaml (known: {known})."
1963
+ )
1964
+ return _ResolvedBench(
1965
+ key=key,
1966
+ name=preset.name,
1967
+ command_template=preset.command_template,
1968
+ runs=preset.runs if preset.runs is not None else base_runs,
1969
+ results_dir=preset.results_dir if preset.results_dir is not None else base_results,
1970
+ readiness_timeout_s=readiness,
1971
+ )
1888
1972
 
1889
1973
 
1890
1974
  @router.post("/api/launcher/sweep/start")
1891
1975
  async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
1892
1976
  """ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
1893
1977
  _require_launcher_token(request)
1978
+ if req.bench_command is not None or req.results_dir is not None:
1979
+ raise HTTPException(
1980
+ status_code=400,
1981
+ detail=(
1982
+ "bench_command / results_dir are no longer accepted from the request. "
1983
+ "Declare the command under launcher.bench.presets in providers.yaml and "
1984
+ "pass its key as 'bench_preset'."
1985
+ ),
1986
+ )
1894
1987
  app = request.app
1895
1988
 
1896
1989
  existing = getattr(app.state, "launcher_sweep", None)
@@ -1901,12 +1994,12 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1901
1994
 
1902
1995
  cfg = app.state.config
1903
1996
  launcher_cfg = getattr(cfg, "launcher", None)
1904
- default_template, default_runs, default_results_dir, default_readiness = (
1905
- _bench_defaults(launcher_cfg)
1906
- )
1907
- bench_command = req.bench_command or default_template
1908
- runs = req.runs if req.runs is not None else default_runs
1909
- results_dir = req.results_dir if req.results_dir is not None else default_results_dir
1997
+ try:
1998
+ bench = _resolve_bench(launcher_cfg, req.bench_preset)
1999
+ except ValueError as exc:
2000
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
2001
+ runs = req.runs if req.runs is not None else bench.runs
2002
+ _assert_model_path_allowed(launcher_cfg, req.model_path, field="model_path")
1910
2003
 
1911
2004
  # ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
1912
2005
  reg = _registry_for_app(app)
@@ -1945,17 +2038,60 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1945
2038
  model_path=req.model_path,
1946
2039
  backend=req.backend,
1947
2040
  port=req.port,
1948
- bench_cmd_template=bench_command,
1949
- results_dir=results_dir,
2041
+ bench_cmd_template=bench.command_template,
2042
+ results_dir=bench.results_dir,
1950
2043
  options=req.options,
1951
2044
  extra_args=req.extra_args,
1952
2045
  )
1953
2046
  runner = _SweepRunner(
1954
- app, launcher_cfg, plan, runs=runs, readiness_timeout_s=default_readiness
2047
+ app, launcher_cfg, plan, runs=runs, readiness_timeout_s=bench.readiness_timeout_s
1955
2048
  )
1956
2049
  app.state.launcher_sweep = runner
1957
2050
  runner.start()
1958
- return {"sweep_id": runner.sweep_id, "steps": [s.as_dict() for s in steps]}
2051
+ return {
2052
+ "sweep_id": runner.sweep_id,
2053
+ "steps": [s.as_dict() for s in steps],
2054
+ "bench_preset": bench.key,
2055
+ }
2056
+
2057
+
2058
+ @router.get("/api/launcher/bench-presets")
2059
+ async def api_bench_presets(request: Request) -> dict[str, Any]:
2060
+ """スイープ UI が選べるベンチプリセットのキー一覧。読み取り系 → 認証なし。
2061
+
2062
+ H-2: command_template はローカルパスが混ざりうるので **返さない**。
2063
+ key/name/runs/has_results_dir だけ返す。presets に "default" が無ければ
2064
+ 暗黙 default(launcher.bench.command_template 由来)を先頭に挿入する。
2065
+ """
2066
+ cfg = request.app.state.config
2067
+ launcher_cfg = getattr(cfg, "launcher", None)
2068
+ bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
2069
+ presets = _bench_preset_map(launcher_cfg)
2070
+ default_preset = getattr(bench_cfg, "default_preset", None)
2071
+ base_results = getattr(bench_cfg, "results_dir", None)
2072
+ base_runs = getattr(bench_cfg, "runs", None) or _HARDCODED_BENCH_RUNS
2073
+ items: list[dict[str, Any]] = []
2074
+ if "default" not in presets:
2075
+ items.append(
2076
+ {
2077
+ "key": "default",
2078
+ "name": "default (launcher.bench.command_template)",
2079
+ "runs": base_runs,
2080
+ "has_results_dir": base_results is not None,
2081
+ }
2082
+ )
2083
+ for key, preset in presets.items():
2084
+ items.append(
2085
+ {
2086
+ "key": key,
2087
+ "name": preset.name,
2088
+ "runs": preset.runs if preset.runs is not None else base_runs,
2089
+ "has_results_dir": (
2090
+ preset.results_dir is not None or base_results is not None
2091
+ ),
2092
+ }
2093
+ )
2094
+ return {"presets": items, "default": default_preset}
1959
2095
 
1960
2096
 
1961
2097
  @router.get("/api/launcher/sweep/status")
@@ -2048,6 +2184,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
2048
2184
  <a href="/dashboard" class="text-slate-400 hover:text-slate-200 transition-colors">Dashboard</a>
2049
2185
  <span class="text-slate-100 font-medium border-b border-indigo-400 pb-0.5">Launcher</span>
2050
2186
  <span id="status-msg" class="ml-auto text-xs text-slate-500"></span>
2187
+ <button id="token-badge" onclick="promptToken()" class="btn-sm btn-red hidden">🔑 トークン未設定</button>
2051
2188
  </div>
2052
2189
  </header>
2053
2190
 
@@ -2213,18 +2350,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2213
2350
  <input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
2214
2351
  </div>
2215
2352
  <div>
2216
- <label class="block text-xs text-slate-400 mb-1">ベンチコマンド ({port} {config} {base_url} {results_dir} {runs} を置換)</label>
2217
- <input id="sweep-cmd" type="text" placeholder="llmbench run --model local-openai --runs {runs}" />
2353
+ <label class="block text-xs text-slate-400 mb-1">ベンチプリセット (providers.yaml の launcher.bench.presets)</label>
2354
+ <select id="sweep-preset"></select>
2218
2355
  </div>
2219
- <div class="grid grid-cols-2 gap-2">
2220
- <div>
2221
- <label class="block text-xs text-slate-400 mb-1">runs</label>
2222
- <input id="sweep-runs" type="number" value="5" min="1" max="1000" />
2223
- </div>
2224
- <div>
2225
- <label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
2226
- <input id="sweep-results" type="text" placeholder="results/" />
2227
- </div>
2356
+ <div>
2357
+ <label class="block text-xs text-slate-400 mb-1">runs</label>
2358
+ <input id="sweep-runs" type="number" value="5" min="1" max="1000" />
2228
2359
  </div>
2229
2360
  </div>
2230
2361
  </div>
@@ -2269,15 +2400,31 @@ _LAUNCHER_HTML = r"""<!doctype html>
2269
2400
  "use strict";
2270
2401
 
2271
2402
  const POLL_MS = 3000;
2272
- // H8: launcher shared-secret. The server substitutes __LAUNCHER_TOKEN__
2273
- // with the configured token (or an empty string when auth is disabled).
2274
- const LAUNCHER_TOKEN = "__LAUNCHER_TOKEN__";
2275
- // Build headers for state-changing requests, adding the token only when set.
2403
+ // H-3: the token is NEVER embedded in the HTML. The server only tells us
2404
+ // whether auth is required; the token itself is entered by the operator and
2405
+ // kept in sessionStorage (cleared when the tab closes).
2406
+ const AUTH_REQUIRED = __LAUNCHER_AUTH_REQUIRED__;
2407
+ const TOKEN_KEY = "coderouter-launcher-token";
2408
+ let launcherToken = sessionStorage.getItem(TOKEN_KEY) || "";
2276
2409
  const authHeaders = (base) => {
2277
2410
  const h = Object.assign({}, base || {});
2278
- if (LAUNCHER_TOKEN) h["X-CodeRouter-Token"] = LAUNCHER_TOKEN;
2411
+ if (launcherToken) h["X-CodeRouter-Token"] = launcherToken;
2279
2412
  return h;
2280
2413
  };
2414
+ const updateTokenBadge = () => {
2415
+ const el = document.getElementById("token-badge");
2416
+ if (!el) return;
2417
+ el.textContent = launcherToken ? "🔑 トークン設定済" : "🔑 トークン未設定";
2418
+ el.className = "btn-sm " + (launcherToken ? "btn-slate" : "btn-red") + (AUTH_REQUIRED ? "" : " hidden");
2419
+ };
2420
+ window.promptToken = () => {
2421
+ const v = window.prompt("CODEROUTER_LAUNCHER_TOKEN を入力してください", "");
2422
+ if (v === null) return;
2423
+ launcherToken = v.trim();
2424
+ if (launcherToken) sessionStorage.setItem(TOKEN_KEY, launcherToken);
2425
+ else sessionStorage.removeItem(TOKEN_KEY);
2426
+ updateTokenBadge();
2427
+ };
2281
2428
  let allProfiles = {}; // backend → [{name, args}]
2282
2429
  const _modelCache = {}; // index → {path, name, dir, size_gb}
2283
2430
  let selectedLogId = null;
@@ -2808,14 +2955,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2808
2955
  tensor_split: c.tensor_split, backend: c.backend || null}));
2809
2956
  if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
2810
2957
  const port = parseInt(document.getElementById("sweep-port").value);
2811
- const cmd = document.getElementById("sweep-cmd").value.trim();
2958
+ const preset = document.getElementById("sweep-preset").value || null;
2812
2959
  const runs = parseInt(document.getElementById("sweep-runs").value);
2813
- const results = document.getElementById("sweep-results").value.trim();
2814
2960
  const body = {
2815
2961
  backend: "llama.cpp", model_path: model, port, configs,
2816
- bench_command: cmd || null,
2962
+ bench_preset: preset,
2817
2963
  runs: isNaN(runs) ? null : runs,
2818
- results_dir: results || null,
2819
2964
  };
2820
2965
  try {
2821
2966
  const r = await fetch("/api/launcher/sweep/start", {
@@ -2970,12 +3115,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2970
3115
  const modelName = p.model_path.split("/").pop();
2971
3116
  const isActive = p.status === "running" || p.status === "loading" || p.status === "starting";
2972
3117
  const stopBtn = isActive
2973
- ? `<button onclick="stopProc('${p.id}')" class="btn-sm btn-red">■ 停止</button>`
3118
+ ? `<button data-act="stop" data-id="${esc(p.id)}" class="btn-sm btn-red">■ 停止</button>`
2974
3119
  : "";
2975
3120
  const delBtn = !isActive
2976
- ? `<button onclick="deleteProc('${p.id}')" class="btn-sm btn-slate ml-1">✕</button>`
3121
+ ? `<button data-act="del" data-id="${esc(p.id)}" class="btn-sm btn-slate ml-1">✕</button>`
2977
3122
  : "";
2978
- const logBtn = `<button onclick="openLog('${p.id}','${esc(p.name)}')" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
3123
+ const logBtn = `<button data-act="log" data-id="${esc(p.id)}" data-name="${esc(p.name)}" class="btn-sm btn-indigo ml-1">📋 ログ</button>`;
2979
3124
  // [Unreleased]: swap badge — marks processes SwapManager spawned
2980
3125
  // on demand, distinct from manually-started ones. Title shows the
2981
3126
  // catalog model name when the backend supplied it.
@@ -2994,6 +3139,18 @@ _LAUNCHER_HTML = r"""<!doctype html>
2994
3139
  }).join("");
2995
3140
  };
2996
3141
 
3142
+ // M-2: no inline onclick with interpolated process fields (XSS). The row
3143
+ // buttons carry data-* attributes; a single delegated listener dispatches.
3144
+ document.getElementById("proc-table").addEventListener("click", (ev) => {
3145
+ const btn = ev.target.closest("button[data-act]");
3146
+ if (!btn) return;
3147
+ const id = btn.getAttribute("data-id");
3148
+ const act = btn.getAttribute("data-act");
3149
+ if (act === "stop") stopProc(id);
3150
+ else if (act === "del") deleteProc(id);
3151
+ else if (act === "log") openLog(id, btn.getAttribute("data-name") || "");
3152
+ });
3153
+
2997
3154
  window.stopProc = async (id) => {
2998
3155
  if (!confirm("プロセスを停止しますか?")) return;
2999
3156
  const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST", headers: authHeaders()});
@@ -3051,11 +3208,35 @@ _LAUNCHER_HTML = r"""<!doctype html>
3051
3208
  document.getElementById("log-panel").classList.add("hidden");
3052
3209
  };
3053
3210
 
3211
+ // H-2: fill the sweep preset <select> from config-declared presets. The
3212
+ // command_template is never sent to the browser; the operator only picks a key.
3213
+ const fetchBenchPresets = async () => {
3214
+ try {
3215
+ const r = await fetch("/api/launcher/bench-presets");
3216
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
3217
+ const d = await r.json();
3218
+ const sel = document.getElementById("sweep-preset");
3219
+ if (!sel) return;
3220
+ const presets = d.presets || [];
3221
+ sel.innerHTML = presets.map((p) => {
3222
+ const runs = (p.runs != null) ? ` — runs ${p.runs}` : "";
3223
+ return `<option value="${esc(p.key)}">${esc(p.name)}${runs}</option>`;
3224
+ }).join("");
3225
+ const chosen = d.default || "default";
3226
+ const keys = presets.map((p) => p.key);
3227
+ sel.value = keys.includes(chosen) ? chosen : (keys[0] || "");
3228
+ } catch (e) {
3229
+ console.error("[Launcher] fetchBenchPresets failed:", e);
3230
+ }
3231
+ };
3232
+
3054
3233
  // ── Init + polling ───────────────────────────────────────────────────────
3055
3234
 
3056
3235
  const init = async () => {
3057
3236
  updateDeviceBlock();
3058
- await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
3237
+ updateTokenBadge();
3238
+ if (AUTH_REQUIRED && !launcherToken) promptToken();
3239
+ await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses(), fetchBenchPresets()]);
3059
3240
  pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
3060
3241
  };
3061
3242
 
@@ -3078,18 +3259,13 @@ _LAUNCHER_HTML = r"""<!doctype html>
3078
3259
  async def launcher_page() -> HTMLResponse:
3079
3260
  """Serve the launcher single-page UI.
3080
3261
 
3081
- H8: inject the configured launcher token so the inline JS can attach it
3082
- to state-changing requests. When the env var is unset the placeholder
3083
- becomes an empty string and the UI keeps working unauthenticated (the
3084
- historical local-only default). The token is escaped for a JS string
3085
- context to avoid breaking out of the double-quoted literal.
3262
+ H-3: the launcher token is NEVER embedded in the served HTML (it used to be
3263
+ substituted into a JS string literal, so anyone who could read the page —
3264
+ including a browser cache or a shoulder-surfer — recovered the shared
3265
+ secret). We now only substitute a boolean ``__LAUNCHER_AUTH_REQUIRED__``
3266
+ telling the client whether auth is on; the operator enters the token in the
3267
+ browser and it lives in ``sessionStorage`` only.
3086
3268
  """
3087
- token = os.environ.get(_LAUNCHER_TOKEN_ENV, "")
3088
- safe_token = (
3089
- token.replace("\\", "\\\\")
3090
- .replace('"', '\\"')
3091
- .replace("<", "\\u003c")
3092
- .replace(">", "\\u003e")
3093
- )
3094
- html = _LAUNCHER_HTML.replace("__LAUNCHER_TOKEN__", safe_token)
3269
+ auth_required = "true" if os.environ.get(_LAUNCHER_TOKEN_ENV, "") else "false"
3270
+ html = _LAUNCHER_HTML.replace("__LAUNCHER_AUTH_REQUIRED__", auth_required)
3095
3271
  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, ""))
@@ -629,6 +629,10 @@ def render_bench_command(
629
629
  argv 化。
630
630
 
631
631
  ``str.format`` は JSON 波括弧等で誤爆するため単純置換を使う。
632
+
633
+ H-2: 置換より **先に** ``shlex.split`` でトークン化し、各トークン内でのみ
634
+ 置換する。こうしないと ``{config}`` にスペースを含む値が入ったとき argv が
635
+ 増殖し(引数注入)、想定外のフラグを外部ベンチに渡せてしまう。
632
636
  """
633
637
  mapping = {
634
638
  "port": str(port),
@@ -637,13 +641,16 @@ def render_bench_command(
637
641
  "results_dir": results_dir or "",
638
642
  "runs": str(runs) if runs is not None else "",
639
643
  }
640
- rendered = template
641
- for k, v in mapping.items():
642
- rendered = rendered.replace("{" + k + "}", v)
643
644
  # ★ Windows: shlex の POSIX モードはバックスラッシュをエスケープ文字
644
645
  # として食い潰し `C:\tools\llmbench.exe` を壊す。os.name で分岐して
645
646
  # Windows では posix=False(バックスラッシュを素通し)にする。
646
- return shlex.split(rendered, posix=(os.name != "nt"))
647
+ argv: list[str] = []
648
+ for tok in shlex.split(template, posix=(os.name != "nt")):
649
+ for k, v in mapping.items():
650
+ tok = tok.replace("{" + k + "}", v)
651
+ if tok:
652
+ argv.append(tok)
653
+ return argv
647
654
 
648
655
 
649
656
  # ---------------------------------------------------------------------------