coderouter-cli 2.11.2__py3-none-any.whl → 2.13.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.
@@ -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
 
@@ -714,11 +755,21 @@ async def _backend_ready(backend: str, port: int, *, probe_timeout_s: float) ->
714
755
  Falling through to the bare TCP connect here would silently re-introduce
715
756
  the bug readiness gating was added to fix (provider registered before the
716
757
  model finished loading).
758
+
759
+ The probe targets ``127.0.0.1`` literally, never ``localhost``. On a host
760
+ where ``localhost`` resolves to ``::1`` first — the default on GitHub's
761
+ macOS runners, and common on Macs generally — an httpx request to
762
+ ``http://localhost:<port>`` fails with ``Address family not supported``
763
+ against a backend listening on IPv4 only, which is what ``llama-server``
764
+ does by default (``--host 127.0.0.1``). Readiness then never succeeds and
765
+ the spawn times out with ``status='loading'`` even though the backend is
766
+ up and serving. The bare TCP fallback below already used ``127.0.0.1``;
767
+ this branch was the odd one out.
717
768
  """
718
769
  if base_backend(backend) in ("llama.cpp", "vllm"):
719
770
  try:
720
771
  async with httpx.AsyncClient(timeout=probe_timeout_s) as client:
721
- resp = await client.get(f"http://localhost:{port}/health")
772
+ resp = await client.get(f"http://127.0.0.1:{port}/health")
722
773
  return resp.status_code == 200
723
774
  except Exception:
724
775
  return False
@@ -1400,6 +1451,8 @@ async def api_start(req: StartRequest, request: Request) -> dict[str, Any]:
1400
1451
  cfg = request.app.state.config
1401
1452
  launcher_cfg = getattr(cfg, "launcher", None)
1402
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")
1403
1456
  # デバイス選択 → CLI 断片(llama.cpp のみ・選択された場合のみ)。未指定なら
1404
1457
  # None を渡し、既存の argv と完全一致(後方互換)。
1405
1458
  device_args: list[str] | None = None
@@ -1655,8 +1708,10 @@ class SweepRequest(BaseModel):
1655
1708
  options: dict[str, Any] = Field(default_factory=dict)
1656
1709
  extra_args: str = ""
1657
1710
  configs: list[SweepConfigItem] = Field(default_factory=list)
1658
- bench_command: str | None = None # None なら launcher.bench 既定
1659
- 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
1660
1715
  results_dir: str | None = None
1661
1716
 
1662
1717
 
@@ -1862,25 +1917,73 @@ class _SweepRunner:
1862
1917
  await self._safe_stop(mp.id)
1863
1918
 
1864
1919
 
1865
- def _bench_defaults(launcher_cfg: Any) -> tuple[str, int, str | None, float]:
1866
- """launcher.bench(あれば)からベンチ既定を取り出す。無ければハードコード。"""
1867
- default_template = "llmbench run --model local-openai --runs {runs}"
1868
- default_runs = 5
1869
- default_results_dir: str | None = None
1870
- 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]:
1871
1935
  bench_cfg = getattr(launcher_cfg, "bench", None) if launcher_cfg else None
1872
- if bench_cfg is not None:
1873
- default_template = bench_cfg.command_template
1874
- default_runs = bench_cfg.runs
1875
- default_results_dir = bench_cfg.results_dir
1876
- default_readiness = bench_cfg.readiness_timeout_s
1877
- 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
+ )
1878
1972
 
1879
1973
 
1880
1974
  @router.post("/api/launcher/sweep/start")
1881
1975
  async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]:
1882
1976
  """ベンチスイープを開始する(設計 §4.4 / §4.5)。書き込み系 → token 必須。"""
1883
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
+ )
1884
1987
  app = request.app
1885
1988
 
1886
1989
  existing = getattr(app.state, "launcher_sweep", None)
@@ -1891,12 +1994,12 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1891
1994
 
1892
1995
  cfg = app.state.config
1893
1996
  launcher_cfg = getattr(cfg, "launcher", None)
1894
- default_template, default_runs, default_results_dir, default_readiness = (
1895
- _bench_defaults(launcher_cfg)
1896
- )
1897
- bench_command = req.bench_command or default_template
1898
- runs = req.runs if req.runs is not None else default_runs
1899
- 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")
1900
2003
 
1901
2004
  # ポート競合: registry の使用中ポート照合 + best-effort な空きチェック。
1902
2005
  reg = _registry_for_app(app)
@@ -1935,17 +2038,60 @@ async def api_sweep_start(req: SweepRequest, request: Request) -> dict[str, Any]
1935
2038
  model_path=req.model_path,
1936
2039
  backend=req.backend,
1937
2040
  port=req.port,
1938
- bench_cmd_template=bench_command,
1939
- results_dir=results_dir,
2041
+ bench_cmd_template=bench.command_template,
2042
+ results_dir=bench.results_dir,
1940
2043
  options=req.options,
1941
2044
  extra_args=req.extra_args,
1942
2045
  )
1943
2046
  runner = _SweepRunner(
1944
- 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
1945
2048
  )
1946
2049
  app.state.launcher_sweep = runner
1947
2050
  runner.start()
1948
- 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}
1949
2095
 
1950
2096
 
1951
2097
  @router.get("/api/launcher/sweep/status")
@@ -2038,6 +2184,7 @@ _LAUNCHER_HTML = r"""<!doctype html>
2038
2184
  <a href="/dashboard" class="text-slate-400 hover:text-slate-200 transition-colors">Dashboard</a>
2039
2185
  <span class="text-slate-100 font-medium border-b border-indigo-400 pb-0.5">Launcher</span>
2040
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>
2041
2188
  </div>
2042
2189
  </header>
2043
2190
 
@@ -2203,18 +2350,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2203
2350
  <input id="sweep-port" type="number" value="8090" min="1024" max="65535" />
2204
2351
  </div>
2205
2352
  <div>
2206
- <label class="block text-xs text-slate-400 mb-1">ベンチコマンド ({port} {config} {base_url} {results_dir} {runs} を置換)</label>
2207
- <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>
2208
2355
  </div>
2209
- <div class="grid grid-cols-2 gap-2">
2210
- <div>
2211
- <label class="block text-xs text-slate-400 mb-1">runs</label>
2212
- <input id="sweep-runs" type="number" value="5" min="1" max="1000" />
2213
- </div>
2214
- <div>
2215
- <label class="block text-xs text-slate-400 mb-1">results_dir (任意)</label>
2216
- <input id="sweep-results" type="text" placeholder="results/" />
2217
- </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" />
2218
2359
  </div>
2219
2360
  </div>
2220
2361
  </div>
@@ -2259,15 +2400,31 @@ _LAUNCHER_HTML = r"""<!doctype html>
2259
2400
  "use strict";
2260
2401
 
2261
2402
  const POLL_MS = 3000;
2262
- // H8: launcher shared-secret. The server substitutes __LAUNCHER_TOKEN__
2263
- // with the configured token (or an empty string when auth is disabled).
2264
- const LAUNCHER_TOKEN = "__LAUNCHER_TOKEN__";
2265
- // 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) || "";
2266
2409
  const authHeaders = (base) => {
2267
2410
  const h = Object.assign({}, base || {});
2268
- if (LAUNCHER_TOKEN) h["X-CodeRouter-Token"] = LAUNCHER_TOKEN;
2411
+ if (launcherToken) h["X-CodeRouter-Token"] = launcherToken;
2269
2412
  return h;
2270
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
+ };
2271
2428
  let allProfiles = {}; // backend → [{name, args}]
2272
2429
  const _modelCache = {}; // index → {path, name, dir, size_gb}
2273
2430
  let selectedLogId = null;
@@ -2798,14 +2955,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2798
2955
  tensor_split: c.tensor_split, backend: c.backend || null}));
2799
2956
  if (!configs.length) { showSweepErr("構成を 1 つ以上選択してください"); return; }
2800
2957
  const port = parseInt(document.getElementById("sweep-port").value);
2801
- const cmd = document.getElementById("sweep-cmd").value.trim();
2958
+ const preset = document.getElementById("sweep-preset").value || null;
2802
2959
  const runs = parseInt(document.getElementById("sweep-runs").value);
2803
- const results = document.getElementById("sweep-results").value.trim();
2804
2960
  const body = {
2805
2961
  backend: "llama.cpp", model_path: model, port, configs,
2806
- bench_command: cmd || null,
2962
+ bench_preset: preset,
2807
2963
  runs: isNaN(runs) ? null : runs,
2808
- results_dir: results || null,
2809
2964
  };
2810
2965
  try {
2811
2966
  const r = await fetch("/api/launcher/sweep/start", {
@@ -2960,12 +3115,12 @@ _LAUNCHER_HTML = r"""<!doctype html>
2960
3115
  const modelName = p.model_path.split("/").pop();
2961
3116
  const isActive = p.status === "running" || p.status === "loading" || p.status === "starting";
2962
3117
  const stopBtn = isActive
2963
- ? `<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>`
2964
3119
  : "";
2965
3120
  const delBtn = !isActive
2966
- ? `<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>`
2967
3122
  : "";
2968
- 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>`;
2969
3124
  // [Unreleased]: swap badge — marks processes SwapManager spawned
2970
3125
  // on demand, distinct from manually-started ones. Title shows the
2971
3126
  // catalog model name when the backend supplied it.
@@ -2984,6 +3139,18 @@ _LAUNCHER_HTML = r"""<!doctype html>
2984
3139
  }).join("");
2985
3140
  };
2986
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
+
2987
3154
  window.stopProc = async (id) => {
2988
3155
  if (!confirm("プロセスを停止しますか?")) return;
2989
3156
  const r = await fetch(`/api/launcher/stop/${id}`, {method:"POST", headers: authHeaders()});
@@ -3041,11 +3208,35 @@ _LAUNCHER_HTML = r"""<!doctype html>
3041
3208
  document.getElementById("log-panel").classList.add("hidden");
3042
3209
  };
3043
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
+
3044
3233
  // ── Init + polling ───────────────────────────────────────────────────────
3045
3234
 
3046
3235
  const init = async () => {
3047
3236
  updateDeviceBlock();
3048
- await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses()]);
3237
+ updateTokenBadge();
3238
+ if (AUTH_REQUIRED && !launcherToken) promptToken();
3239
+ await Promise.all([fetchModels(), fetchProfiles(), fetchBackends(), fetchProcesses(), fetchBenchPresets()]);
3049
3240
  pollSweep(); // 既存スイープがあれば表示 (running なら以後のポーリングを継続)
3050
3241
  };
3051
3242
 
@@ -3068,18 +3259,13 @@ _LAUNCHER_HTML = r"""<!doctype html>
3068
3259
  async def launcher_page() -> HTMLResponse:
3069
3260
  """Serve the launcher single-page UI.
3070
3261
 
3071
- H8: inject the configured launcher token so the inline JS can attach it
3072
- to state-changing requests. When the env var is unset the placeholder
3073
- becomes an empty string and the UI keeps working unauthenticated (the
3074
- historical local-only default). The token is escaped for a JS string
3075
- 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.
3076
3268
  """
3077
- token = os.environ.get(_LAUNCHER_TOKEN_ENV, "")
3078
- safe_token = (
3079
- token.replace("\\", "\\\\")
3080
- .replace('"', '\\"')
3081
- .replace("<", "\\u003c")
3082
- .replace(">", "\\u003e")
3083
- )
3084
- 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)
3085
3271
  return HTMLResponse(content=html)
@@ -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
  # ---------------------------------------------------------------------------
coderouter/py.typed ADDED
File without changes