coderouter-cli 2.12.0__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.
coderouter/cli.py CHANGED
@@ -600,28 +600,27 @@ def _run_check_model(args: argparse.Namespace) -> int:
600
600
 
601
601
 
602
602
  def _resolve_config_path(explicit: str | None) -> Path:
603
- """Mirror loader._candidate_paths and return the file actually used.
604
-
605
- Used by ``--apply`` to write back to the same path the loader
606
- picked up when it parsed providers.yaml. Falls through the same
607
- search order so a ``CODEROUTER_CONFIG`` env or default-path lookup
608
- matches the live config.
603
+ """Return the file the loader actually read, for ``--apply`` write-back.
604
+
605
+ Delegates the search order to
606
+ :func:`coderouter.config.loader.resolve_config_path` rather than
607
+ re-implementing it. Re-implementing it here is exactly how the
608
+ v2.13.0 CWD opt-in change would introduce a silent bug: if the loader
609
+ gates ``./providers.yaml`` behind ``CODEROUTER_ALLOW_CWD_CONFIG`` but
610
+ this copy still searched CWD unconditionally, ``doctor --apply`` would
611
+ write patches back into a file that ``load_config`` never read. Keeping
612
+ one source of truth makes that impossible.
613
+
614
+ When nothing exists, falls back to ``~/.coderouter/providers.yaml``
615
+ (the loader's last candidate) — the apply step surfaces a clearer
616
+ error against that path than this resolver would.
609
617
  """
610
- import os
611
-
612
- candidates: list[Path] = []
613
- if explicit:
614
- candidates.append(Path(explicit))
615
- if env_path := os.environ.get("CODEROUTER_CONFIG"):
616
- candidates.append(Path(env_path))
617
- candidates.append(Path.cwd() / "providers.yaml")
618
- candidates.append(Path.home() / ".coderouter" / "providers.yaml")
619
- for p in candidates:
620
- if p.is_file():
621
- return p
622
- # Fall back to the last candidate even if absent — the apply step
623
- # will surface a clearer error than this resolver would.
624
- return candidates[-1]
618
+ from coderouter.config.loader import resolve_config_path
619
+
620
+ resolved = resolve_config_path(explicit)
621
+ if resolved is not None:
622
+ return resolved
623
+ return Path.home() / ".coderouter" / "providers.yaml"
625
624
 
626
625
 
627
626
  def _write_verdict_line(*, write: bool, files_written: int) -> None:
@@ -3,21 +3,26 @@
3
3
  Search order (first hit wins):
4
4
  1. Path passed explicitly (CLI --config flag)
5
5
  2. $CODEROUTER_CONFIG env var
6
- 3. ./providers.yaml (current working dir)
6
+ 3. ./providers.yaml (current working dir) — opt-in since v2.13.0,
7
+ gated behind ``CODEROUTER_ALLOW_CWD_CONFIG`` (truthy: 1/true/yes/on)
7
8
  4. ~/.coderouter/providers.yaml
8
9
 
9
10
  Secrets are resolved by reading the env var named by `api_key_env`.
10
11
 
11
12
  .. note::
12
- **[Unreleased] deprecation notice (planned v2.12.0):** step 3 above
13
- (implicit CWD discovery) is slated to become opt-in, gated behind
14
- ``CODEROUTER_ALLOW_CWD_CONFIG``. Landing that change without warning
15
- first would silently break anyone currently relying on "run from the
16
- directory with providers.yaml in it" with no diagnostic. Until that
17
- lands, :func:`load_config` emits a one-time ``cwd-config-loaded``
18
- warning whenever step 3 is the one that actually resolved — never
19
- when an explicit ``--config`` / ``CODEROUTER_CONFIG`` path was used,
20
- even if it happens to point at the same file.
13
+ **v2.13.0 (security):** step 3 above (implicit CWD discovery) is now
14
+ opt-in, gated behind ``CODEROUTER_ALLOW_CWD_CONFIG``. It was a code
15
+ execution vector: a hostile ``providers.yaml`` dropped into a repo
16
+ could steer ``restart_command`` / ``launcher.backends[*].binary`` /
17
+ ``launcher.bench.command_template`` — all of which name executables —
18
+ simply because CodeRouter happened to be started from that directory.
19
+ When the opt-in is enabled and step 3 resolves the config, a one-time
20
+ ``cwd-config-loaded`` warning fires (never when an explicit
21
+ ``--config`` / ``CODEROUTER_CONFIG`` path was used, even if it happens
22
+ to point at the same file). When the opt-in is NOT set but a
23
+ ``./providers.yaml`` exists and was not explicitly named, a one-time
24
+ ``cwd-config-skipped`` warning fires so the operator understands why
25
+ that file was ignored.
21
26
  """
22
27
 
23
28
  from __future__ import annotations
@@ -32,10 +37,25 @@ from coderouter.logging import get_logger
32
37
 
33
38
  logger = get_logger(__name__)
34
39
 
35
- # Guards the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning
36
- # (see module docstring) so a process that calls load_config() repeatedly
37
- # (tests, hot-reload, multiple create_app() calls) only logs it once.
40
+ # Name of the env var that opts CWD ``providers.yaml`` discovery back in
41
+ # (see module docstring). v2.13.0 gates step 3 of the search behind it.
42
+ CWD_CONFIG_ENV = "CODEROUTER_ALLOW_CWD_CONFIG"
43
+ # Truthy vocabulary shared by every CWD-opt-in / ALLOW_PAID style toggle.
44
+ _TRUTHY = {"1", "true", "yes", "on"}
45
+
46
+ # Guards the one-time ``cwd-config-loaded`` warning (opt-in enabled and the
47
+ # CWD step actually resolved) so a process that calls load_config()
48
+ # repeatedly (tests, hot-reload, multiple create_app() calls) only logs it
49
+ # once.
38
50
  _cwd_config_warning_emitted = False
51
+ # Guards the one-time ``cwd-config-skipped`` warning (opt-in NOT set but a
52
+ # ./providers.yaml exists and was ignored) — same once-per-process rationale.
53
+ _cwd_config_skip_warning_emitted = False
54
+
55
+
56
+ def cwd_config_allowed() -> bool:
57
+ """True iff the CWD ``providers.yaml`` opt-in is enabled (v2.13.0)."""
58
+ return os.environ.get(CWD_CONFIG_ENV, "").strip().lower() in _TRUTHY
39
59
 
40
60
 
41
61
  def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
@@ -44,27 +64,58 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
44
64
  paths.append(Path(explicit))
45
65
  if env_path := os.environ.get("CODEROUTER_CONFIG"):
46
66
  paths.append(Path(env_path))
47
- paths.append(Path.cwd() / "providers.yaml")
67
+ # v2.13.0: implicit CWD discovery is opt-in — a hostile providers.yaml
68
+ # dropped into a repo could otherwise steer restart_command / launcher
69
+ # binaries simply because CodeRouter was started from that directory.
70
+ if cwd_config_allowed():
71
+ paths.append(Path.cwd() / "providers.yaml")
48
72
  paths.append(Path.home() / ".coderouter" / "providers.yaml")
49
73
  return paths
50
74
 
51
75
 
76
+ def resolve_config_path(
77
+ explicit: str | os.PathLike[str] | None = None,
78
+ ) -> Path | None:
79
+ """Return the config file the loader would pick, or None if none exists.
80
+
81
+ Single source of truth for the search order so callers (e.g.
82
+ ``coderouter.cli._resolve_config_path`` driving ``doctor --apply``)
83
+ never re-implement it. Re-implementing it risks writing back to a
84
+ different file than the one :func:`load_config` actually read — the
85
+ exact regression the v2.13.0 CWD opt-in would otherwise introduce.
86
+ """
87
+ return next((p for p in _candidate_paths(explicit) if p.is_file()), None)
88
+
89
+
90
+ def _explicitly_named(
91
+ path: Path, explicit: str | os.PathLike[str] | None
92
+ ) -> bool:
93
+ """True iff ``path`` matches the literal --config or CODEROUTER_CONFIG value.
94
+
95
+ Used to keep the CWD warnings quiet when ``./providers.yaml`` was
96
+ chosen on purpose (an explicit choice, however coincidental, is not
97
+ the implicit-discovery behaviour being gated).
98
+ """
99
+ if explicit and Path(explicit) == path:
100
+ return True
101
+ env_path = os.environ.get("CODEROUTER_CONFIG")
102
+ return env_path is not None and Path(env_path) == path
103
+
104
+
52
105
  def _warn_if_cwd_config(
53
106
  chosen: Path, *, explicit: str | os.PathLike[str] | None
54
107
  ) -> None:
55
- """Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
108
+ """Emit the one-time ``cwd-config-loaded`` warning (opt-in is enabled).
56
109
 
57
110
  Only fires when ``chosen`` was resolved via the *implicit* CWD-search
58
- step. Compares ``chosen`` against the *literal* explicit / env
59
- candidate paths (not merely whether those inputs were set) so that:
60
-
61
- * an explicit ``--config`` (or ``CODEROUTER_CONFIG``) path that
62
- happens to coincide with ``./providers.yaml`` stays quiet — an
63
- explicit choice, however coincidental, is not the thing being
64
- deprecated; but
65
- * an explicit path/env var that was set but did NOT resolve (file
66
- missing, so the search fell through to CWD) still warns, because
67
- the CWD step is what actually served the config in that case.
111
+ step — which, since v2.13.0, only runs when
112
+ ``CODEROUTER_ALLOW_CWD_CONFIG`` is set. Compares ``chosen`` against the
113
+ *literal* explicit / env candidate paths (not merely whether those
114
+ inputs were set) so that an explicit ``--config`` (or
115
+ ``CODEROUTER_CONFIG``) path that happens to coincide with
116
+ ``./providers.yaml`` stays quiet, while an explicit path/env var that
117
+ was set but did NOT resolve (file missing, search fell through to CWD)
118
+ still warns — the CWD step is what actually served the config there.
68
119
  """
69
120
  global _cwd_config_warning_emitted
70
121
  if _cwd_config_warning_emitted:
@@ -72,9 +123,7 @@ def _warn_if_cwd_config(
72
123
  cwd_path = Path.cwd() / "providers.yaml"
73
124
  if chosen != cwd_path:
74
125
  return
75
- if explicit and Path(explicit) == chosen:
76
- return
77
- if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
126
+ if _explicitly_named(chosen, explicit):
78
127
  return
79
128
 
80
129
  _cwd_config_warning_emitted = True
@@ -84,26 +133,76 @@ def _warn_if_cwd_config(
84
133
  "path": str(chosen),
85
134
  "hint": (
86
135
  f"loaded providers.yaml from the current working "
87
- f"directory ({chosen}). Starting in v2.12.0, this "
88
- "implicit CWD discovery becomes opt-in (gated behind "
89
- "CODEROUTER_ALLOW_CWD_CONFIG) — pass --config or set "
90
- "CODEROUTER_CONFIG to pin the file explicitly and avoid "
91
- "a startup break when that lands."
136
+ f"directory ({chosen}) because CODEROUTER_ALLOW_CWD_CONFIG "
137
+ "opt-in is enabled. This file's restart_command / "
138
+ "launcher.backends[*].binary / "
139
+ "launcher.bench.command_template decide which executables "
140
+ "run — only enable the opt-in in directories you trust."
92
141
  ),
93
142
  },
94
143
  )
95
144
 
96
145
 
146
+ def _warn_if_cwd_config_skipped(
147
+ explicit: str | os.PathLike[str] | None,
148
+ ) -> Path | None:
149
+ """Warn once when a ./providers.yaml exists but was skipped (no opt-in).
150
+
151
+ Returns the skipped CWD path (so :func:`load_config` can name it in a
152
+ later ``FileNotFoundError``), or None when there is nothing to skip:
153
+ the opt-in is enabled, no ``./providers.yaml`` exists, or the file was
154
+ explicitly named (``--config`` / ``CODEROUTER_CONFIG``) — in which case
155
+ it is not being skipped at all.
156
+ """
157
+ global _cwd_config_skip_warning_emitted
158
+ if cwd_config_allowed():
159
+ return None
160
+ cwd_path = Path.cwd() / "providers.yaml"
161
+ if not cwd_path.is_file():
162
+ return None
163
+ if _explicitly_named(cwd_path, explicit):
164
+ return None
165
+
166
+ if not _cwd_config_skip_warning_emitted:
167
+ _cwd_config_skip_warning_emitted = True
168
+ logger.warning(
169
+ "cwd-config-skipped",
170
+ extra={
171
+ "path": str(cwd_path),
172
+ "hint": (
173
+ f"found providers.yaml in the current working "
174
+ f"directory ({cwd_path}) but did NOT load it: implicit "
175
+ "CWD discovery is opt-in since v2.13.0 (a hostile "
176
+ "providers.yaml can steer restart_command / launcher "
177
+ "binaries). To use it, set CODEROUTER_ALLOW_CWD_CONFIG=1 "
178
+ "(only in directories you trust) or pass --config "
179
+ f"{cwd_path} to name it explicitly."
180
+ ),
181
+ },
182
+ )
183
+ return cwd_path
184
+
185
+
97
186
  def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
98
187
  """Load providers.yaml + apply ALLOW_PAID env override."""
188
+ skipped_cwd = _warn_if_cwd_config_skipped(path)
99
189
  candidates = _candidate_paths(path)
100
190
  chosen: Path | None = next((p for p in candidates if p.is_file()), None)
101
191
  if chosen is None:
102
192
  searched = "\n ".join(str(p) for p in candidates)
103
- raise FileNotFoundError(
193
+ message = (
104
194
  f"providers.yaml not found. Searched:\n {searched}\n"
105
195
  f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
106
196
  )
197
+ if skipped_cwd is not None:
198
+ message += (
199
+ f"\nNote: {skipped_cwd} exists but was NOT read — implicit "
200
+ "CWD discovery is opt-in since v2.13.0. Three ways to use "
201
+ f"it: pass --config {skipped_cwd}, set "
202
+ f"CODEROUTER_CONFIG={skipped_cwd}, or set "
203
+ "CODEROUTER_ALLOW_CWD_CONFIG=1 (only in directories you trust)."
204
+ )
205
+ raise FileNotFoundError(message)
107
206
  _warn_if_cwd_config(chosen, explicit=path)
108
207
 
109
208
  with chosen.open("r", encoding="utf-8") as f:
@@ -355,15 +355,20 @@ class AgentCliConfig(BaseModel):
355
355
  return self
356
356
 
357
357
 
358
- # [Unreleased]/planned v2.12.0: patterns used by ProviderConfig's
358
+ # v2.13.0: patterns used by ProviderConfig's
359
359
  # ``_warn_restart_command_shell_syntax`` validator to flag
360
- # ``restart_command`` values that rely on shell syntax not honored by
361
- # the planned ``shlex.split`` + ``shell=False`` dispatch (see that
362
- # validator's docstring and the field description below for the full
363
- # rationale). Compiled once at module scope since every ProviderConfig
364
- # validation reuses them.
365
- _RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
366
- _RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
360
+ # ``restart_command`` values that rely on shell syntax not honored by the
361
+ # ``shlex.split`` + ``shell=False`` dispatch (see that validator's
362
+ # docstring and the field description below for the full rationale).
363
+ # Public (no leading underscore) because ``coderouter.guards.self_healing``
364
+ # imports ``RESTART_COMMAND_SHELL_META_RE`` to *refuse* to run any
365
+ # restart_command containing shell metacharacters — under shell=False a
366
+ # value like ``touch a; b`` would create a file literally named ``a;`` and
367
+ # exit 0, i.e. report a "successful" restart while only half-executing.
368
+ # Compiled once at module scope since every ProviderConfig validation
369
+ # reuses them.
370
+ RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
371
+ RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
367
372
 
368
373
 
369
374
  class ProviderConfig(BaseModel):
@@ -520,54 +525,56 @@ class ProviderConfig(BaseModel):
520
525
  "transitions to UNHEALTHY. Unset = no automatic restart "
521
526
  "(recovery probe still runs, waiting for manual restart). "
522
527
  "\n\n"
523
- "[Unreleased]/planned v2.12.0: currently run as "
524
- "``subprocess.run(command, shell=True)`` (coderouter/guards/"
525
- "self_healing.py), so shell syntax works today — pipelines "
526
- "(``pkill ollama && ollama serve``), ``~`` expansion, and "
527
- "leading env-var assignments (``OLLAMA_HOST=0.0.0.0 ollama "
528
- "serve``) are all honored. A future minor switches this to "
529
- "``shlex.split(command)`` + ``shell=False`` (argv dispatch, "
530
- "no shell in between), which does NOT support any of the "
531
- "above — ``&&`` becomes a literal argument to the first "
532
- "command instead of a separator, ``~/...`` paths are not "
533
- "expanded, and ``FOO=bar cmd`` fails to exec. If your "
534
- "command needs shell features, wrap it in a small script "
535
- "and point restart_command at that script instead, e.g. "
536
- "``restart_command: /path/to/restart-ollama.sh``."
528
+ "v2.13.0: run as ``subprocess.run(shlex.split(command), "
529
+ "shell=False)`` (coderouter/guards/self_healing.py) — argv "
530
+ "dispatch, no shell in between. Pipelines "
531
+ "(``pkill ollama && ollama serve``), redirects, ``~`` "
532
+ "expansion, and leading env-var assignments "
533
+ "(``OLLAMA_HOST=0.0.0.0 ollama serve``) are NOT supported: "
534
+ "``~/...`` paths are not expanded and ``FOO=bar cmd`` fails to "
535
+ "exec. A value containing shell metacharacters (``&&``, ``||``, "
536
+ "``|``, ``;``, ``>``, ``<``, ``$(...)``, ```...```) is refused "
537
+ "outright (not run) rather than half-executed. If your command "
538
+ "needs shell features, wrap it in a small script and point "
539
+ "restart_command at that script (e.g. "
540
+ "``restart_command: /path/to/restart-ollama.sh``), or write it "
541
+ "as ``/bin/sh -c '...'`` explicitly."
537
542
  ),
538
543
  )
539
544
 
540
545
  @model_validator(mode="after")
541
546
  def _warn_restart_command_shell_syntax(self) -> ProviderConfig:
542
- """[Unreleased]/planned v2.12.0 breaking-change heads-up (warning only).
543
-
544
- ``coderouter/guards/self_healing.py`` currently runs
545
- ``restart_command`` via ``subprocess.run(command, shell=True)``.
546
- The planned v2.12.0 change switches that to
547
- ``shlex.split(command)`` + ``shell=False`` — a shell-syntax
548
- ``restart_command`` that works fine today would then either
549
- crash (``FileNotFoundError`` for ``~/...`` paths or an
550
- ``ENV=val cmd`` prefix) or, worse, silently misbehave (``&&`` /
551
- ``||`` / ``|`` / ``;`` / redirects becoming literal argv tokens
552
- instead of shell operators — a provider "restart" that quietly
553
- does the wrong thing is worse than one that errors loudly).
547
+ """v2.13.0: warn that this ``restart_command`` will not run as written.
548
+
549
+ ``coderouter/guards/self_healing.py`` runs ``restart_command`` via
550
+ ``subprocess.run(shlex.split(command), shell=False)`` — argv
551
+ dispatch, no shell. A shell-syntax ``restart_command`` therefore
552
+ does NOT do what the shell would do:
553
+
554
+ * shell metacharacters (``&&`` / ``||`` / ``|`` / ``;`` /
555
+ redirects / ``$(...)`` / backticks) → the value is *refused*
556
+ (not run) by self_healing, because under shell=False they would
557
+ otherwise become literal argv tokens and half-execute a
558
+ provider "restart" that quietly does the wrong thing;
559
+ * a leading ``~/...`` path or an ``ENV=val cmd`` prefix →
560
+ ``exec`` fails (``FileNotFoundError``) because there is no shell
561
+ to expand ``~`` or apply the assignment.
554
562
 
555
563
  Deliberately never raises: this validator does not enforce
556
- anything, it only warns ahead of the wire change so operators
557
- can migrate ``restart_command`` to a plain argv (or a wrapper
558
- script) before v2.12.0 lands, instead of discovering the break
559
- the next time a provider actually goes UNHEALTHY.
564
+ anything, it only warns so an existing providers.yaml still loads
565
+ (rather than becoming un-startable). Migrate ``restart_command`` to
566
+ a plain argv, a wrapper script, or an explicit ``/bin/sh -c '...'``.
560
567
  """
561
568
  cmd = self.restart_command
562
569
  if not cmd:
563
570
  return self
564
571
  stripped = cmd.strip()
565
572
  reasons: list[str] = []
566
- if _RESTART_COMMAND_SHELL_META_RE.search(cmd):
573
+ if RESTART_COMMAND_SHELL_META_RE.search(cmd):
567
574
  reasons.append("shell metacharacter (&&, ||, |, ;, >, <, $(...), or `...`)")
568
575
  if stripped.startswith("~/"):
569
576
  reasons.append("leading '~/' path (no shell expansion under shell=False)")
570
- if _RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
577
+ if RESTART_COMMAND_ENV_PREFIX_RE.match(stripped):
571
578
  reasons.append("leading environment-variable assignment (FOO=bar ...)")
572
579
  if reasons:
573
580
  logger.warning(
@@ -577,13 +584,13 @@ class ProviderConfig(BaseModel):
577
584
  "restart_command": cmd,
578
585
  "reasons": reasons,
579
586
  "hint": (
580
- "restart_command currently runs via "
581
- "subprocess.run(..., shell=True); a planned "
582
- "v2.12.0 change switches to shlex.split(...) + "
583
- "shell=False, which does not support this "
584
- "command's shell syntax. Rewrite it as plain "
585
- "argv, or wrap it in a script and point "
586
- "restart_command at that script."
587
+ "restart_command runs via "
588
+ "subprocess.run(shlex.split(...), shell=False) as of "
589
+ "v2.13.0, which does not support this command's "
590
+ "shell syntax: values with shell metacharacters are "
591
+ "refused (not run), and '~/' / 'FOO=bar' prefixes "
592
+ "fail to exec. Rewrite it as plain argv, wrap it in "
593
+ "a script, or use /bin/sh -c '...'."
587
594
  ),
588
595
  },
589
596
  )
@@ -1573,6 +1580,28 @@ class LauncherOptionProfile(BaseModel):
1573
1580
  )
1574
1581
 
1575
1582
 
1583
+ class LauncherBenchPreset(BaseModel):
1584
+ """launcher.bench.presets[<key>] の1エントリ。H-2: スイープAPIが受け付けるのはキー名だけ。"""
1585
+
1586
+ model_config = ConfigDict(extra="forbid")
1587
+ name: str = Field(..., min_length=1, description="Launcher UI に出す表示名。")
1588
+ command_template: str = Field(
1589
+ ...,
1590
+ min_length=1,
1591
+ description="外部ベンチコマンドのテンプレ。{port} {config} {base_url} {results_dir} {runs} を単純置換で展開。",
1592
+ )
1593
+ runs: int | None = Field(
1594
+ default=None,
1595
+ ge=1,
1596
+ le=1000,
1597
+ description="このプリセット固有の実行回数。None なら launcher.bench.runs。",
1598
+ )
1599
+ results_dir: str | None = Field(
1600
+ default=None,
1601
+ description="このプリセット固有の results ディレクトリ。None なら launcher.bench.results_dir。リクエストからは指定できない。",
1602
+ )
1603
+
1604
+
1576
1605
  class LauncherBenchConfig(BaseModel):
1577
1606
  """The ``launcher.bench:`` block — defaults for the bench sweep feature.
1578
1607
 
@@ -1614,6 +1643,23 @@ class LauncherBenchConfig(BaseModel):
1614
1643
  "大きな GGUF ロードを考慮した既定 5 分。"
1615
1644
  ),
1616
1645
  )
1646
+ presets: dict[str, LauncherBenchPreset] = Field(
1647
+ default_factory=dict,
1648
+ description="名前付きベンチコマンド。キーが POST /api/launcher/sweep/start の bench_preset が取りうる唯一の値。",
1649
+ )
1650
+ default_preset: str | None = Field(
1651
+ default=None,
1652
+ description="bench_preset 未指定時に使うプリセットキー。None なら command_template 由来の暗黙 default。",
1653
+ )
1654
+
1655
+ @model_validator(mode="after")
1656
+ def _check_default_preset(self) -> Self:
1657
+ if self.default_preset is not None and self.default_preset not in self.presets:
1658
+ raise ValueError(
1659
+ f"launcher.bench.default_preset={self.default_preset!r} is not a key of "
1660
+ f"launcher.bench.presets (known: {sorted(self.presets) or 'none'})"
1661
+ )
1662
+ return self
1617
1663
 
1618
1664
 
1619
1665
  class SwapModelSpec(BaseModel):
@@ -50,6 +50,14 @@ Design choices
50
50
  are guarded independently.
51
51
  - **No new dependency** — subprocess (stdlib) for restart commands,
52
52
  asyncio for recovery probe scheduling.
53
+ - **v2.13.0: argv dispatch (shell=False)** — ``restart_command`` is run
54
+ as ``subprocess.run(shlex.split(command), shell=False)``, never through
55
+ a shell. A value containing shell metacharacters is refused outright
56
+ (not run), because under shell=False those tokens would half-execute
57
+ the command (e.g. ``touch a; b`` would create a file named ``a;`` and
58
+ exit 0, falsely reporting a successful restart). This closes the H-1
59
+ code-execution vector where a hostile CWD providers.yaml could drive
60
+ arbitrary shell through self-healing.
53
61
  - **Restart is opt-in** — only providers with ``restart_command``
54
62
  set get automatic restart. Others rely solely on recovery probes
55
63
  (waiting for manual restart by the operator).
@@ -62,12 +70,14 @@ from __future__ import annotations
62
70
 
63
71
  import asyncio
64
72
  import contextlib
73
+ import os
74
+ import shlex
65
75
  import subprocess
66
76
  import threading
67
77
  import time
68
78
  from dataclasses import dataclass
69
79
 
70
- from coderouter.config.schemas import ProviderConfig
80
+ from coderouter.config.schemas import RESTART_COMMAND_SHELL_META_RE, ProviderConfig
71
81
  from coderouter.logging import (
72
82
  get_logger,
73
83
  log_self_healing_exclude,
@@ -268,6 +278,51 @@ class SelfHealingOrchestrator:
268
278
 
269
279
  provider = provider_config.name
270
280
 
281
+ # v2.13.0: argv dispatch (shell=False). We validate/parse the command
282
+ # BEFORE taking the per-provider restart lock so a malformed value
283
+ # short-circuits without ever touching the lock (and a later valid
284
+ # attempt is unaffected).
285
+ #
286
+ # A-5: refuse anything with shell metacharacters. Under shell=False,
287
+ # ``touch a; b`` would create a file literally named ``a;`` and exit
288
+ # 0 — reporting a "successful" restart while only half-executing the
289
+ # intended pipeline. Refusing loudly beats half-running silently.
290
+ if RESTART_COMMAND_SHELL_META_RE.search(command):
291
+ log_self_healing_restart(
292
+ logger,
293
+ provider=provider,
294
+ command=command,
295
+ success=False,
296
+ error=(
297
+ "restart_command contains shell syntax but is no longer "
298
+ "run through a shell (v2.13.0). Wrap it in a script and "
299
+ "point restart_command at that script, or write it as "
300
+ "/bin/sh -c '...'."
301
+ ),
302
+ )
303
+ return False
304
+
305
+ try:
306
+ argv = shlex.split(command, posix=(os.name != "nt"))
307
+ except ValueError as exc:
308
+ log_self_healing_restart(
309
+ logger,
310
+ provider=provider,
311
+ command=command,
312
+ success=False,
313
+ error=f"unparsable restart_command: {exc}",
314
+ )
315
+ return False
316
+ if not argv:
317
+ log_self_healing_restart(
318
+ logger,
319
+ provider=provider,
320
+ command=command,
321
+ success=False,
322
+ error="restart_command is empty after parsing",
323
+ )
324
+ return False
325
+
271
326
  # Get or create a per-provider lock.
272
327
  with self._lock:
273
328
  if provider not in self._restart_locks:
@@ -281,8 +336,8 @@ class SelfHealingOrchestrator:
281
336
 
282
337
  try:
283
338
  result = subprocess.run(
284
- command,
285
- shell=True,
339
+ argv,
340
+ shell=False,
286
341
  capture_output=True,
287
342
  timeout=timeout_s,
288
343
  text=True,
coderouter/ingress/app.py CHANGED
@@ -10,7 +10,7 @@ from contextlib import asynccontextmanager
10
10
  from fastapi import FastAPI, Request
11
11
  from fastapi.responses import JSONResponse
12
12
  from starlette.middleware.base import BaseHTTPMiddleware
13
- from starlette.types import ASGIApp
13
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
14
14
 
15
15
  from coderouter import __version__
16
16
  from coderouter.config import load_config
@@ -101,37 +101,78 @@ def _parse_max_body_bytes(raw: str | None) -> int:
101
101
  return value if value > 0 else _DEFAULT_MAX_BODY_BYTES
102
102
 
103
103
 
104
- class BodySizeLimitMiddleware(BaseHTTPMiddleware):
105
- """Reject requests whose declared body exceeds the configured cap.
104
+ class _BodyTooLarge(Exception):
105
+ pass
106
106
 
107
- The check is on the incoming ``Content-Length`` header, so it is cheap
108
- (no body is read) and fires before the route handler runs. Streaming
109
- *responses* (SSE) are unaffected — this only inspects the request side.
110
- A body over the limit fails closed with 413.
107
+
108
+ class BodySizeLimitMiddleware:
109
+ """Reject requests whose body exceeds the configured cap.
110
+
111
+ M-3: this is a **pure ASGI** middleware, not a ``BaseHTTPMiddleware``.
112
+ The old dispatch only checked the ``Content-Length`` header, so a chunked
113
+ request (no Content-Length) bypassed the cap entirely. We now also count
114
+ the bytes actually received and fail closed with 413 the moment the total
115
+ crosses the limit. It must be pure ASGI because a ``BaseHTTPMiddleware``
116
+ that consumes ``request.stream()`` to measure the body would hand a
117
+ drained (empty) body to the downstream route.
118
+
119
+ Streaming *responses* (SSE) are unaffected — this only inspects the
120
+ request side.
111
121
  """
112
122
 
113
123
  def __init__(self, app: ASGIApp, max_bytes: int) -> None:
114
- super().__init__(app)
124
+ self.app = app
115
125
  self._max_bytes = max_bytes
116
126
 
117
- async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
118
- content_length = request.headers.get("content-length")
119
- if content_length is not None:
120
- try:
121
- declared = int(content_length)
122
- except ValueError:
123
- declared = -1
124
- if declared > self._max_bytes:
125
- return JSONResponse(
126
- status_code=413,
127
- content={
128
- "detail": (
129
- f"Request body too large: {declared} bytes exceeds "
130
- f"the {self._max_bytes}-byte limit."
131
- )
132
- },
127
+ def _too_large(self, observed: int) -> JSONResponse:
128
+ return JSONResponse(
129
+ status_code=413,
130
+ content={
131
+ "detail": (
132
+ f"Request body too large: {observed} bytes exceeds "
133
+ f"the {self._max_bytes}-byte limit."
133
134
  )
134
- return await call_next(request)
135
+ },
136
+ )
137
+
138
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
139
+ if scope["type"] != "http":
140
+ await self.app(scope, receive, send)
141
+ return
142
+ for key, value in scope.get("headers", []):
143
+ if key == b"content-length":
144
+ try:
145
+ declared = int(value)
146
+ except ValueError:
147
+ break
148
+ if declared > self._max_bytes:
149
+ await self._too_large(declared)(scope, receive, send)
150
+ return
151
+ break
152
+ received = 0
153
+ response_started = False
154
+
155
+ async def limited_receive() -> Message:
156
+ nonlocal received
157
+ message = await receive()
158
+ if message["type"] == "http.request":
159
+ received += len(message.get("body", b""))
160
+ if received > self._max_bytes:
161
+ raise _BodyTooLarge(received)
162
+ return message
163
+
164
+ async def guarded_send(message: Message) -> None:
165
+ nonlocal response_started
166
+ if message["type"] == "http.response.start":
167
+ response_started = True
168
+ await send(message)
169
+
170
+ try:
171
+ await self.app(scope, limited_receive, guarded_send)
172
+ except _BodyTooLarge as exc:
173
+ if response_started:
174
+ raise
175
+ await self._too_large(int(exc.args[0]))(scope, receive, send)
135
176
 
136
177
 
137
178
  class HostValidationMiddleware(BaseHTTPMiddleware):
@@ -409,10 +450,12 @@ def create_app(config_path: str | None = None) -> FastAPI:
409
450
  )
410
451
  app.add_middleware(HostValidationMiddleware, allowed_hosts=allowed_hosts)
411
452
 
412
- # M14: request body size limit (DoS protection). Added after the Host
413
- # middleware so that — because add_middleware wraps outermost-last — Host
414
- # validation runs first and the size guard second. SSE streaming responses
415
- # are unaffected: the check only reads the request Content-Length.
453
+ # M14: request body size limit (DoS protection). ``add_middleware`` wraps
454
+ # outermost-last, so the middleware added *after* HostValidation ends up
455
+ # the outermost layer — i.e. the size guard runs FIRST, before Host
456
+ # validation. That ordering is harmless: an oversized/chunked body is
457
+ # rejected as early as possible. SSE streaming responses are unaffected;
458
+ # this only inspects the request side.
416
459
  max_body_bytes = _parse_max_body_bytes(
417
460
  os.environ.get(_MAX_BODY_BYTES_ENV)
418
461
  )
@@ -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)
@@ -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
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.12.0
3
+ Version: 2.13.0
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -1,6 +1,6 @@
1
1
  coderouter/__init__.py,sha256=ghdjPrLtnRzY8fyQ4CJZI1UJKADyNTLtA3G7se8H7Ns,696
2
2
  coderouter/__main__.py,sha256=-LCgxJnvgUV240HjQKv7ly-mn2NuKHpC4nCpvTHjeSU,130
3
- coderouter/cli.py,sha256=2g-0HILLM16mNnkFUOR6lWXy5f6Y0LipCjo_YH4--j0,36896
3
+ coderouter/cli.py,sha256=TyI29d8-Zc8SClcKn92Oj8DGyQRxMpBSWTxLiFTH3Qo,37043
4
4
  coderouter/cli_stats.py,sha256=CCjzc1G4hTRHZ2gG1XhxhDpUkJnnl3NXbcbp1T18jpg,29894
5
5
  coderouter/cost.py,sha256=32h6uzb4nxh2eA5d2Hn3kD9yJbtis6CFDAbeIy5KRkM,7431
6
6
  coderouter/doctor.py,sha256=wmPunlvqm2s7x3VGLjexqHPLD2oejwE3Mu04V8y-xn4,89869
@@ -10,7 +10,7 @@ coderouter/errors.py,sha256=Xmq67lheyw8iv3Ox39jh2c4tvNI5RcUR4QkoxVDN6l4,1130
10
10
  coderouter/gguf_introspect.py,sha256=Scu0JjUfLHj7T7TL0HQIm1AZcGYrXsjkaMMSN9HkTro,16121
11
11
  coderouter/hardware.py,sha256=gn3_9qbVcGRR81yKMn1lJE_8-YDRau0LxIH_M-f7pxE,8356
12
12
  coderouter/language_tax.py,sha256=LTbE3tIfoJuV2O3T0NixRKhzq_dEOTUuPEerJv2q9uk,9360
13
- coderouter/launcher_devices.py,sha256=Jd2hVJMsQZ0x6YFc_2ROvEc2L10b6dh77Y-gvP_Po8M,29039
13
+ coderouter/launcher_devices.py,sha256=DAV34Q1crGptmj1zOAk21YQzlxQNAt5Ud7qcldTD95w,29406
14
14
  coderouter/launcher_speculative.py,sha256=h0RTGsD1Y56sJVywDJwybrT75_Ta3GF1Jz_B1d42aPg,12931
15
15
  coderouter/launcher_swap.py,sha256=tMW5ZElDdf9T1VQGsUkvfLiSCdj_oXN_UtkV7DE8ySY,24175
16
16
  coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
@@ -26,8 +26,8 @@ coderouter/adapters/registry.py,sha256=XNc1wmFv_oZlqKS0rA693TRDdbq1sZ-OIcofXOVOt
26
26
  coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g,274
27
27
  coderouter/config/capability_registry.py,sha256=RZgzHF54dXy0CCDocRCz2ee21qXGbbgTSKTkRI9iLL4,16936
28
28
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
29
- coderouter/config/loader.py,sha256=jUoHAbEyjK6Y0HDqhjXHO4tfncMPuzsjfWJokrPb2x8,6876
30
- coderouter/config/schemas.py,sha256=AK2lsPMqBa6NYEXNHwxis8Nd9bcW2q_x4ciO3QuYIpk,134441
29
+ coderouter/config/loader.py,sha256=vk1LFm-OCiyq2GB5G53g7kTnrJgmQjkZPqgr9X13wpc,11294
30
+ coderouter/config/schemas.py,sha256=J05km7-YXgNxy6guKUDcNsuMyJ21SnDIurjx8Rz_hSg,136672
31
31
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
32
32
  coderouter/data/model-capabilities.yaml,sha256=S9jt6SC6-3s2-icZ_n-a14iEMnc2yB1C2R6q-N_tZWQ,19309
33
33
  coderouter/guards/__init__.py,sha256=5qliYBqygvVPneej7nx0uSjxDKsz7t8VzvrDgVBJlvU,1170
@@ -39,13 +39,13 @@ coderouter/guards/drift_actions.py,sha256=A6pY5CR480Ct5rCVyjlBvjPFVc93eu_r5qcUpK
39
39
  coderouter/guards/drift_detection.py,sha256=lW45EZDGRleDUTstw_CHlfUiwLUrlDHX8g7ncuOYNEw,13962
40
40
  coderouter/guards/memory_budget.py,sha256=_bRtusk4AwrU781wVXW32OFU7zD2FXxOwTb7yGqPJqA,8278
41
41
  coderouter/guards/memory_pressure.py,sha256=9Xm-b7rv9Td5kPNmAFoL3sNmaOR5zT8NLdn6erWcuyY,8719
42
- coderouter/guards/self_healing.py,sha256=Qene73S1kqMJn8HYzHO-WT8hhWxKbo2v_JtXtEgpCp8,14434
42
+ coderouter/guards/self_healing.py,sha256=XeUlgFWGqMQ5E9YvvzR2CsHRJOAJEyDChqRN0xmZ0vo,16841
43
43
  coderouter/guards/tool_loop.py,sha256=EzeMcmU7BLeTW2jsRVevU81l5rhWcn1oUr7EpzgXjVM,15209
44
44
  coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLug,155
45
45
  coderouter/ingress/anthropic_routes.py,sha256=1ThXzx8VgxRV7UCi3NF5ey7kbr0ADPKTXyQuYS5gK1w,27063
46
- coderouter/ingress/app.py,sha256=6e_qNlYZIyjLKhY20t0ZNL-x5Fv8R8oaIH_HCdxa9Ic,20921
46
+ coderouter/ingress/app.py,sha256=ZOx7dSvMxaWS4CzXOOEwgzFuycAVgAeWKCN6lFNF-F0,22433
47
47
  coderouter/ingress/dashboard_routes.py,sha256=1SqNNrVTfQiZcx5n2MvjAzWirittX0e61tCt-MKp4Ag,25617
48
- coderouter/ingress/launcher_routes.py,sha256=ipgY1omiSGQn5RBvsuQMLepCJ03w4eafacg2JDVufe8,133937
48
+ coderouter/ingress/launcher_routes.py,sha256=uP_Otp9JE0fydUt1YP-wYcnS8Kj5RPLNIFf88YpfkPM,141535
49
49
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
50
50
  coderouter/ingress/openai_routes.py,sha256=TrugsQNJfNPgKKLzfT1aXzrs4emWYGE4XebEBuaZoWk,12505
51
51
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
@@ -72,8 +72,8 @@ coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcG
72
72
  coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
73
73
  coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
74
74
  coderouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
- coderouter_cli-2.12.0.dist-info/METADATA,sha256=B1BU4vM2YPNzqR8sMFJy7vfPPUqBliwXqqpiLESl7mE,16897
76
- coderouter_cli-2.12.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
77
- coderouter_cli-2.12.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
78
- coderouter_cli-2.12.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
79
- coderouter_cli-2.12.0.dist-info/RECORD,,
75
+ coderouter_cli-2.13.0.dist-info/METADATA,sha256=J6h8klUNsTIhWoMjrax70AR9nCFXYK_rFP2tbpChn8I,16897
76
+ coderouter_cli-2.13.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
77
+ coderouter_cli-2.13.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
78
+ coderouter_cli-2.13.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
79
+ coderouter_cli-2.13.0.dist-info/RECORD,,