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.
- coderouter/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +159 -22
- coderouter/config/loader.py +157 -35
- coderouter/config/schemas.py +237 -47
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/launcher_routes.py +237 -61
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/launcher_devices.py +11 -4
- 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.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +25 -21
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
runs = req.runs if req.runs is not None else
|
|
1909
|
-
|
|
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=
|
|
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=
|
|
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 {
|
|
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"
|
|
2217
|
-
<
|
|
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
|
|
2220
|
-
<
|
|
2221
|
-
|
|
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
|
-
//
|
|
2273
|
-
//
|
|
2274
|
-
|
|
2275
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
3121
|
+
? `<button data-act="del" data-id="${esc(p.id)}" class="btn-sm btn-slate ml-1">✕</button>`
|
|
2977
3122
|
: "";
|
|
2978
|
-
const logBtn = `<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
|
-
|
|
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
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
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
|
-
|
|
3088
|
-
|
|
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
|
-
|
|
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/launcher_devices.py
CHANGED
|
@@ -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
|
-
|
|
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
|
# ---------------------------------------------------------------------------
|