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.
@@ -0,0 +1,332 @@
1
+ """Credential resolution, including CLI-session borrowing (v2.14.0).
2
+
3
+ Until now a provider's credential could only be an env var. That is fine
4
+ for API keys and useless for the thing an increasing number of vendors
5
+ actually sell: a subscription, authenticated by an OAuth token that their
6
+ own CLI already fetched and wrote to disk.
7
+
8
+ CodeRouter's existing answer to those was ``kind: agent_cli`` — spawn the
9
+ vendor's CLI once per request and read its stdout. It works, and it
10
+ throws away almost everything CodeRouter is for. A one-shot ``exec`` has
11
+ no streaming, no tool-call repair, no context-budget guard, and it does
12
+ not sit in a fallback chain the way an HTTP provider does. The vendor's
13
+ subscription ends up as an island.
14
+
15
+ The alternative, which is what codex-router does for Kimi and Grok, is to
16
+ skip the process entirely: **read the token the CLI already wrote, and
17
+ make the HTTP call ourselves.** The provider then becomes an ordinary
18
+ ``openai_compat`` entry, and every routing feature applies to it —
19
+ "use the subscription until it 429s, then fall through to a free cloud
20
+ model, then to local llama.cpp" becomes an ordinary chain.
21
+
22
+ - name: kimi-sub
23
+ kind: openai_compat
24
+ base_url: https://api.moonshot.cn/v1
25
+ credential:
26
+ source: cli_session
27
+ path: ~/.kimi-code/credentials/kimi-code.json
28
+ field: access_token
29
+ expiry_field: expires_at
30
+ refresh:
31
+ command: ["kimi", "auth", "status"]
32
+
33
+ Refresh: delegate, don't reimplement
34
+ ------------------------------------
35
+ Refreshing is the part that rots. Every vendor has its own endpoint,
36
+ client id, rotation policy and error shape, and all of them change. So
37
+ this module does not implement OAuth. It runs the vendor's own CLI and
38
+ re-reads the file — codex-router's approach for Grok, and the reason that
39
+ path has survived contact with a moving vendor while its bespoke Kimi
40
+ refresh has not.
41
+
42
+ The command is an argv **list**, dispatched with ``shell=False``. That is
43
+ the same trust decision v2.13.0 made for ``restart_command``: a string
44
+ that goes through a shell turns a config file into arbitrary code
45
+ execution, and half-executing one (``touch a; b`` under ``shell=False``
46
+ creates a file literally named ``a;`` and reports success) is worse than
47
+ refusing. Here there is no string form to refuse — the schema only
48
+ accepts a list.
49
+
50
+ Concurrency
51
+ -----------
52
+ Two workers hitting an expired token would otherwise both shell out.
53
+ Refresh is single-flighted in-process by a per-path lock, and serialised
54
+ across processes by an advisory ``flock`` on a sidecar file where the
55
+ platform has one. After acquiring either lock the file is re-read before
56
+ deciding to refresh, because the holder of the lock may already have done
57
+ the work.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import os
64
+ import subprocess
65
+ import threading
66
+ import time
67
+ from dataclasses import dataclass
68
+ from pathlib import Path
69
+ from typing import Any
70
+
71
+ from coderouter.logging import get_logger
72
+ from coderouter.secret_redaction import register_secret
73
+
74
+ __all__ = [
75
+ "CredentialError",
76
+ "resolve_provider_credential",
77
+ ]
78
+
79
+ logger = get_logger(__name__)
80
+
81
+ # One lock per session file, created on demand. Keyed by the resolved
82
+ # absolute path so two providers pointing at the same file share it.
83
+ _locks: dict[str, threading.Lock] = {}
84
+ _locks_guard = threading.Lock()
85
+
86
+
87
+ class CredentialError(RuntimeError):
88
+ """A credential was configured but could not be produced."""
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _SessionRead:
93
+ token: str | None
94
+ expires_at: float | None
95
+
96
+
97
+ def _lock_for(path: str) -> threading.Lock:
98
+ with _locks_guard:
99
+ return _locks.setdefault(path, threading.Lock())
100
+
101
+
102
+ def _dotted_get(payload: Any, dotted: str) -> Any:
103
+ """Fetch ``a.b.c`` out of nested mappings.
104
+
105
+ Vendors nest differently (``access_token`` at the root for Kimi, under
106
+ ``tokens`` for others), so the field is a path rather than a key. A
107
+ missing segment returns ``None`` rather than raising — an absent token
108
+ is a normal state (the operator has not logged in yet) and the caller
109
+ turns it into a clear message.
110
+ """
111
+ current = payload
112
+ for segment in dotted.split("."):
113
+ if not isinstance(current, dict) or segment not in current:
114
+ return None
115
+ current = current[segment]
116
+ return current
117
+
118
+
119
+ def _as_epoch_seconds(value: Any) -> float | None:
120
+ """Normalise an expiry field to epoch seconds.
121
+
122
+ Accepts seconds and milliseconds, because both are common and telling
123
+ them apart is unambiguous: any plausible expiry in seconds is under
124
+ 1e11, and any in milliseconds is over it. A value we cannot read at
125
+ all yields ``None``, which is treated as "no expiry information" —
126
+ the token is used and the upstream 401 becomes the signal instead.
127
+ """
128
+ if isinstance(value, bool) or value is None:
129
+ return None
130
+ if isinstance(value, (int, float)):
131
+ seconds = float(value)
132
+ return seconds / 1000.0 if seconds > 1e11 else seconds
133
+ if isinstance(value, str):
134
+ try:
135
+ return _as_epoch_seconds(float(value))
136
+ except ValueError:
137
+ return None
138
+ return None
139
+
140
+
141
+ def _read_session(path: Path, *, field: str, expiry_field: str) -> _SessionRead:
142
+ """Read the token (and its expiry, if present) out of a session file."""
143
+ try:
144
+ raw = path.read_text(encoding="utf-8")
145
+ except OSError as exc:
146
+ raise CredentialError(f"cannot read session file {path}: {exc}") from exc
147
+ try:
148
+ payload = json.loads(raw)
149
+ except json.JSONDecodeError as exc:
150
+ raise CredentialError(f"session file {path} is not valid JSON: {exc}") from exc
151
+
152
+ token = _dotted_get(payload, field)
153
+ if token is not None and not isinstance(token, str):
154
+ raise CredentialError(
155
+ f"session file {path}: field {field!r} is "
156
+ f"{type(token).__name__}, expected a string"
157
+ )
158
+ expires_at = _as_epoch_seconds(_dotted_get(payload, expiry_field))
159
+ return _SessionRead(token=token or None, expires_at=expires_at)
160
+
161
+
162
+ def _needs_refresh(read: _SessionRead, *, early_ratio: float, min_lead_s: float) -> bool:
163
+ """Whether to refresh now.
164
+
165
+ Refresh early rather than on expiry: a token that dies mid-request
166
+ costs a retry and a confusing upstream error. ``early_ratio`` is a
167
+ fraction of the *remaining* lifetime as seen right now, floored by
168
+ ``min_lead_s`` — so a long-lived token still refreshes several minutes
169
+ before it lapses rather than at the last second.
170
+ """
171
+ if read.token is None:
172
+ return True
173
+ if read.expires_at is None:
174
+ return False
175
+ remaining = read.expires_at - time.time()
176
+ return remaining <= max(min_lead_s, remaining * early_ratio)
177
+
178
+
179
+ def _run_refresh(command: list[str], *, timeout_s: float, path: Path) -> None:
180
+ """Run the vendor CLI so it rotates the token, then let the caller re-read.
181
+
182
+ ``shell=False`` with an argv list: the config file names a program, it
183
+ does not get to compose a shell command. Failures are logged and
184
+ swallowed — a stale-but-present token is still worth trying, and the
185
+ upstream 401 is a better error for the operator than a refresh
186
+ traceback that hides it.
187
+ """
188
+ try:
189
+ completed = subprocess.run(
190
+ command,
191
+ shell=False,
192
+ capture_output=True,
193
+ timeout=timeout_s,
194
+ check=False,
195
+ )
196
+ except (OSError, subprocess.SubprocessError) as exc:
197
+ logger.warning(
198
+ "credential-refresh-failed",
199
+ extra={"path": str(path), "command": command[0], "error": str(exc)},
200
+ )
201
+ return
202
+ if completed.returncode != 0:
203
+ # stderr is deliberately NOT logged: a failing auth CLI is exactly
204
+ # the thing most likely to print a token or a device code.
205
+ logger.warning(
206
+ "credential-refresh-nonzero",
207
+ extra={
208
+ "path": str(path),
209
+ "command": command[0],
210
+ "returncode": completed.returncode,
211
+ },
212
+ )
213
+
214
+
215
+ def _flock(handle: Any, *, exclusive: bool = True) -> Any:
216
+ """Best-effort advisory lock; a no-op where the platform has none."""
217
+ try:
218
+ import fcntl
219
+ except ImportError: # pragma: no cover - Windows
220
+ return None
221
+ fcntl.flock(handle, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
222
+ return fcntl
223
+
224
+
225
+ def _resolve_cli_session(spec: Any, provider_name: str) -> str | None:
226
+ """Read (and if needed refresh) a token a vendor CLI wrote to disk."""
227
+ path = Path(str(spec.path)).expanduser()
228
+ field = spec.field
229
+ expiry_field = spec.expiry_field
230
+
231
+ read = _read_session(path, field=field, expiry_field=expiry_field)
232
+ refresh = spec.refresh
233
+ if refresh is None or not _needs_refresh(
234
+ read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
235
+ ):
236
+ return _register(read.token, provider_name)
237
+
238
+ with _lock_for(str(path)):
239
+ # Re-read under the in-process lock: another worker may have
240
+ # refreshed while we waited.
241
+ read = _read_session(path, field=field, expiry_field=expiry_field)
242
+ if not _needs_refresh(
243
+ read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
244
+ ):
245
+ return _register(read.token, provider_name)
246
+
247
+ lock_path = path.with_name(path.name + ".coderouter-lock")
248
+ try:
249
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
250
+ with open(lock_path, "a+") as handle:
251
+ _flock(handle)
252
+ # And once more across processes, same reason.
253
+ read = _read_session(path, field=field, expiry_field=expiry_field)
254
+ if _needs_refresh(
255
+ read,
256
+ early_ratio=refresh.early_ratio,
257
+ min_lead_s=refresh.min_lead_s,
258
+ ):
259
+ logger.info(
260
+ "credential-refresh-started",
261
+ extra={"provider": provider_name, "command": refresh.command[0]},
262
+ )
263
+ _run_refresh(
264
+ list(refresh.command), timeout_s=refresh.timeout_s, path=path
265
+ )
266
+ read = _read_session(path, field=field, expiry_field=expiry_field)
267
+ except OSError as exc:
268
+ logger.warning(
269
+ "credential-lock-failed",
270
+ extra={"path": str(lock_path), "error": str(exc)},
271
+ )
272
+
273
+ return _register(read.token, provider_name)
274
+
275
+
276
+ def _register(token: str | None, provider_name: str) -> str | None:
277
+ """Arm the log scrubber before the token goes anywhere near a header."""
278
+ if token:
279
+ register_secret(token, f"{provider_name}-session")
280
+ return token
281
+
282
+
283
+ def resolve_provider_credential(config: Any) -> str | None:
284
+ """Return the credential for ``config``, or ``None`` when there is none.
285
+
286
+ Order of precedence is deliberately not "try everything":
287
+ ``credential`` and ``api_key_env`` are mutually exclusive at the
288
+ schema level, so exactly one path can apply. A resolver that silently
289
+ fell back between sources would make "which credential is this request
290
+ actually using?" unanswerable, which is the question an operator asks
291
+ at exactly the wrong moment.
292
+ """
293
+ from coderouter.config.loader import resolve_api_key
294
+
295
+ spec = getattr(config, "credential", None)
296
+ if spec is None:
297
+ return resolve_api_key(getattr(config, "api_key_env", None))
298
+
299
+ name = str(getattr(config, "name", "?"))
300
+ if spec.source == "env":
301
+ return resolve_api_key(spec.env)
302
+ if spec.source == "cli_session":
303
+ try:
304
+ return _resolve_cli_session(spec, name)
305
+ except CredentialError as exc:
306
+ # A missing or malformed session file is an operator problem
307
+ # (not logged in yet, vendor changed its layout), not a crash.
308
+ # Returning None sends the request unauthenticated, the upstream
309
+ # 401 fires, and the fallback chain moves on.
310
+ logger.warning(
311
+ "credential-unavailable",
312
+ extra={"provider": name, "source": spec.source, "error": str(exc)},
313
+ )
314
+ return None
315
+ raise CredentialError(f"provider {name}: unknown credential source {spec.source!r}")
316
+
317
+
318
+ def session_path_is_sane(path: str) -> bool:
319
+ """Reject a session path that reaches outside the user's home.
320
+
321
+ ``providers.yaml`` is operator-owned config, at the same trust level as
322
+ ``launcher.backends[*].binary`` — but v2.13.0 exists because that
323
+ assumption broke once already when a file could be picked up from the
324
+ working directory. A vendor CLI writes under ``$HOME``; nothing
325
+ legitimate needs ``/etc/shadow``.
326
+ """
327
+ try:
328
+ resolved = Path(path).expanduser().resolve()
329
+ home = Path.home().resolve()
330
+ except (OSError, RuntimeError):
331
+ return False
332
+ return resolved == home or home in resolved.parents or os.name == "nt"
@@ -520,6 +520,34 @@ def _resolve_target_path(
520
520
  return None
521
521
 
522
522
 
523
+ def _undo_partial_apply(
524
+ backups: dict[str, str], created_paths: list[Path]
525
+ ) -> int:
526
+ """Put back every file this invocation already rewrote.
527
+
528
+ Best-effort by design: a rollback that raises would mask the original
529
+ write failure, which is the thing the operator actually needs to see.
530
+ Files we created from nothing are removed; files we overwrote are
531
+ restored from the ``.bak`` copy taken moments earlier in the same loop.
532
+
533
+ Returns the number of files successfully reverted, for the error text.
534
+ """
535
+ reverted = 0
536
+ for target_str, backup_str in backups.items():
537
+ try:
538
+ shutil.copy2(backup_str, target_str)
539
+ reverted += 1
540
+ except OSError:
541
+ continue
542
+ for created in created_paths:
543
+ try:
544
+ created.unlink(missing_ok=True)
545
+ reverted += 1
546
+ except OSError:
547
+ continue
548
+ return reverted
549
+
550
+
523
551
  def apply_doctor_patches(
524
552
  *,
525
553
  report: Any, # DoctorReport — typed Any to avoid an import cycle
@@ -637,6 +665,9 @@ def apply_doctor_patches(
637
665
  written = False
638
666
  written_paths: list[str] = []
639
667
  backups: dict[str, str] = {}
668
+ # Files this invocation created from nothing. They have no backup to
669
+ # restore from, so undoing them means deleting them again.
670
+ created_paths: list[Path] = []
640
671
  for target_path in target_order:
641
672
  doc, before_text = docs_by_path[target_path]
642
673
  after_text = _dump_yaml_with_comments(doc)
@@ -656,15 +687,29 @@ def apply_doctor_patches(
656
687
  reformat_only[str(target_path)] = diff_text
657
688
  if write and target_changed:
658
689
  # Backup first (only when we have a pre-existing file).
659
- if target_path.is_file():
660
- backup_path = target_path.with_suffix(
661
- target_path.suffix + PROVIDERS_BACKUP_SUFFIX
662
- )
663
- shutil.copy2(target_path, backup_path)
664
- backups[str(target_path)] = str(backup_path)
665
- else:
666
- target_path.parent.mkdir(parents=True, exist_ok=True)
667
- target_path.write_text(after_text, encoding="utf-8")
690
+ try:
691
+ if target_path.is_file():
692
+ backup_path = target_path.with_suffix(
693
+ target_path.suffix + PROVIDERS_BACKUP_SUFFIX
694
+ )
695
+ shutil.copy2(target_path, backup_path)
696
+ backups[str(target_path)] = str(backup_path)
697
+ else:
698
+ target_path.parent.mkdir(parents=True, exist_ok=True)
699
+ created_paths.append(target_path)
700
+ target_path.write_text(after_text, encoding="utf-8")
701
+ except OSError as exc:
702
+ # v2.14.0: an apply that spans two files used to leave the
703
+ # first one rewritten when the second failed — a half-applied
704
+ # config the operator had no way to name, let alone undo. Roll
705
+ # the completed writes back before surfacing the error, so a
706
+ # failed apply is always a no-op rather than a partial one.
707
+ undone = _undo_partial_apply(backups, created_paths)
708
+ raise DoctorApplyError(
709
+ f"failed to write {target_path}: {exc}. "
710
+ f"Rolled back {undone} already-written file(s); "
711
+ "no partial apply was left on disk."
712
+ ) from exc
668
713
  written = True
669
714
  written_paths.append(str(target_path))
670
715
 
@@ -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
  )