coderouter-cli 2.12.0__py3-none-any.whl → 2.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/adapters/anthropic_native.py +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +159 -22
- coderouter/config/loader.py +157 -35
- coderouter/config/schemas.py +237 -47
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/guards/self_healing.py +58 -3
- coderouter/ingress/app.py +72 -29
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/launcher_routes.py +237 -61
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/launcher_devices.py +11 -4
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +25 -21
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.12.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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"
|
coderouter/doctor_apply.py
CHANGED
|
@@ -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
|
-
|
|
660
|
-
|
|
661
|
-
target_path.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
285
|
-
shell=
|
|
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
|
|
105
|
-
|
|
104
|
+
class _BodyTooLarge(Exception):
|
|
105
|
+
pass
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
124
|
+
self.app = app
|
|
115
125
|
self._max_bytes = max_bytes
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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).
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
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
|
)
|