coderouter-cli 2.13.0__py3-none-any.whl → 2.15.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.
@@ -371,6 +371,120 @@ RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
371
371
  RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
372
372
 
373
373
 
374
+ class CredentialRefresh(BaseModel):
375
+ """How to make a vendor CLI rotate the token it wrote to disk.
376
+
377
+ Deliberately not an OAuth client. Every vendor has its own endpoint,
378
+ client id, rotation policy and error shape, and all of them change —
379
+ delegating to the CLI that owns the file is the version that survives
380
+ contact with a moving vendor.
381
+
382
+ ``command`` is an argv **list**, dispatched with ``shell=False``. Same
383
+ trust decision as v2.13.0's ``restart_command``: a string that goes
384
+ through a shell turns a config file into arbitrary code execution.
385
+ There is no string form here to have to refuse.
386
+ """
387
+
388
+ model_config = ConfigDict(extra="forbid")
389
+
390
+ command: list[str] = Field(
391
+ ...,
392
+ min_length=1,
393
+ description=(
394
+ "argv for the vendor CLI, e.g. ['grok', 'models']. Run with "
395
+ "shell=False; no shell metacharacters are interpreted."
396
+ ),
397
+ )
398
+ early_ratio: float = Field(
399
+ default=0.5,
400
+ ge=0.0,
401
+ le=1.0,
402
+ description=(
403
+ "Refresh when the remaining lifetime drops below this fraction "
404
+ "of itself. Early refresh avoids a token dying mid-request."
405
+ ),
406
+ )
407
+ min_lead_s: float = Field(
408
+ default=300.0,
409
+ ge=0.0,
410
+ description="Always refresh at least this many seconds before expiry.",
411
+ )
412
+ timeout_s: float = Field(
413
+ default=30.0,
414
+ gt=0.0,
415
+ le=600.0,
416
+ description="Hard timeout for the refresh command.",
417
+ )
418
+
419
+
420
+ class ProviderCredential(BaseModel):
421
+ """Where this provider's credential comes from (v2.14.0).
422
+
423
+ ``source: env`` is the historical behaviour, spelled out. ``source:
424
+ cli_session`` borrows the token a vendor CLI already wrote to disk, so
425
+ a subscription-authenticated provider can be an ordinary
426
+ ``openai_compat`` entry instead of a ``kind: agent_cli`` island — and
427
+ therefore takes part in fallback chains, auto-routing, the budget
428
+ tracker and every other routing feature.
429
+ """
430
+
431
+ model_config = ConfigDict(extra="forbid")
432
+
433
+ source: Literal["env", "cli_session"] = Field(
434
+ default="env",
435
+ description="'env' reads an environment variable; 'cli_session' reads a JSON file.",
436
+ )
437
+ env: str | None = Field(
438
+ default=None, description="source=env: the environment variable name."
439
+ )
440
+ path: str | None = Field(
441
+ default=None,
442
+ description=(
443
+ "source=cli_session: the JSON file the vendor CLI writes, e.g. "
444
+ "~/.kimi-code/credentials/kimi-code.json. Must live under $HOME."
445
+ ),
446
+ )
447
+ field: str = Field(
448
+ default="access_token",
449
+ description="Dotted path to the token inside that JSON (e.g. 'tokens.access').",
450
+ )
451
+ expiry_field: str = Field(
452
+ default="expires_at",
453
+ description=(
454
+ "Dotted path to the expiry. Epoch seconds or milliseconds; both "
455
+ "are accepted. Absent means 'no expiry info' — the upstream 401 "
456
+ "becomes the signal instead."
457
+ ),
458
+ )
459
+ refresh: CredentialRefresh | None = Field(
460
+ default=None,
461
+ description="Omit to never refresh (a long-lived token, or one you rotate yourself).",
462
+ )
463
+
464
+ @model_validator(mode="after")
465
+ def _check_source_requirements(self) -> Self:
466
+ """Each source needs its own field, and only its own field."""
467
+ if self.source == "env":
468
+ if not self.env:
469
+ raise ValueError("credential.source='env' requires credential.env")
470
+ if self.path:
471
+ raise ValueError("credential.path is meaningless for source='env'")
472
+ if self.source == "cli_session":
473
+ if not self.path:
474
+ raise ValueError(
475
+ "credential.source='cli_session' requires credential.path"
476
+ )
477
+ if self.env:
478
+ raise ValueError("credential.env is meaningless for source='cli_session'")
479
+ from coderouter.credentials import session_path_is_sane
480
+
481
+ if not session_path_is_sane(self.path):
482
+ raise ValueError(
483
+ f"credential.path must live under your home directory: {self.path}"
484
+ )
485
+ return self
486
+
487
+
374
488
  class ProviderConfig(BaseModel):
375
489
  """A single provider entry from providers.yaml.
376
490
 
@@ -416,6 +530,19 @@ class ProviderConfig(BaseModel):
416
530
  default=None,
417
531
  description="Env var name holding the API key. None = no auth (e.g. local).",
418
532
  )
533
+ # v2.14.0: the general form of the above. Mutually exclusive with
534
+ # api_key_env — a resolver that silently fell back between sources would
535
+ # make "which credential is this request actually using?" unanswerable,
536
+ # which is the question an operator asks at exactly the wrong moment.
537
+ credential: ProviderCredential | None = Field(
538
+ default=None,
539
+ description=(
540
+ "Where the credential comes from. Omit to use api_key_env. "
541
+ "source='cli_session' reads the token a vendor CLI already wrote "
542
+ "to disk, so a subscription provider can be a plain "
543
+ "openai_compat entry that takes part in fallback chains."
544
+ ),
545
+ )
419
546
 
420
547
  # Routing-relevant flags
421
548
  paid: bool = Field(
@@ -611,6 +738,23 @@ class ProviderConfig(BaseModel):
611
738
  validate_output_filters(self.output_filters)
612
739
  return self
613
740
 
741
+ @model_validator(mode="after")
742
+ def _check_credential_exclusive(self) -> Self:
743
+ """``api_key_env`` and ``credential`` cannot both be set.
744
+
745
+ Failing at load time is the whole point: two credential sources on
746
+ one provider is a question ("which one won?") that should never
747
+ reach a request, let alone a log line an operator has to reverse
748
+ engineer at 2am.
749
+ """
750
+ if self.credential is not None and self.api_key_env:
751
+ raise ValueError(
752
+ f"provider {self.name!r}: set either api_key_env or credential, "
753
+ "not both (credential.source='env' with credential.env=... is "
754
+ "the spelled-out form of api_key_env)"
755
+ )
756
+ return self
757
+
614
758
  @model_validator(mode="after")
615
759
  def _check_kind_requirements(self) -> ProviderConfig:
616
760
  """Enforce per-``kind`` field requirements (external-agents design §5.2.2).
@@ -1088,6 +1232,71 @@ class FallbackChain(BaseModel):
1088
1232
  ),
1089
1233
  )
1090
1234
 
1235
+ # --- v2.15.0 (stream-truncation): upstream SSE cut without a terminator --
1236
+ #
1237
+ # A streaming upstream can end its HTTP body *cleanly* while the LLM
1238
+ # protocol carried inside it is still mid-message: no ``message_stop`` on
1239
+ # the Anthropic wire, no ``data: [DONE]`` and no ``finish_reason`` on the
1240
+ # OpenAI wire. llama.cpp slot preemption, an ``--n-predict`` cut-off, a
1241
+ # front proxy closing an EOF-delimited body, or an OOM'd local server all
1242
+ # produce exactly this shape. Transport-level breakage (timeout,
1243
+ # ``httpx.RemoteProtocolError``) was already caught; this is the layer the
1244
+ # transport cannot see.
1245
+ #
1246
+ # Before v2.15.0 that stream was indistinguishable from a complete one:
1247
+ # the adapters never recorded whether a terminator arrived, and the
1248
+ # translation layer's terminator-synthesis guards (H6 / M9 in
1249
+ # ``coderouter.translation.convert``) then fabricated a ``stop_reason:
1250
+ # end_turn`` / ``finish_reason: "stop"`` so the client would not hang. The
1251
+ # synthesis is correct and stays; what was missing is telling the *engine*
1252
+ # that it happened.
1253
+ #
1254
+ # * ``off`` — no detection, no log, no metric. Backward-compatible
1255
+ # default: byte-for-byte identical to v2.14.0 on every path.
1256
+ # * ``warn`` — emit a ``stream-truncation-detected`` log line (and the
1257
+ # ``stream_truncated_total`` metric); the stream still
1258
+ # terminates through the legacy synthesis path, so the
1259
+ # client sees exactly what it saw before.
1260
+ # * ``error`` — the adapter raises ``StreamTruncatedError`` (a retryable
1261
+ # ``AdapterError``) at the point the terminator should have
1262
+ # arrived. The engine's existing branches take over: no
1263
+ # bytes forwarded yet → fall back to the next provider with
1264
+ # reason ``stream-truncated``; bytes already forwarded →
1265
+ # ``MidStreamError``, which ``partial_stitch_action:
1266
+ # surface`` renders as a graceful close carrying a
1267
+ # ``coderouter_partial`` event with reason
1268
+ # ``stream_truncated``.
1269
+ #
1270
+ # Caveats for ``error``:
1271
+ # * False positives are possible against an upstream that legitimately
1272
+ # omits the terminator. A ``message_delta`` carrying a ``stop_reason``
1273
+ # (Anthropic) and a ``finish_reason`` on any choice (OpenAI) are both
1274
+ # accepted as terminators to keep that risk low, but running ``warn``
1275
+ # first to measure the real rate is the recommended rollout.
1276
+ # * Falling back re-generates the answer on the next provider, so the
1277
+ # tokens the truncated attempt burned are paid twice.
1278
+ # * On the Anthropic streaming path the pre-content fallback requires
1279
+ # ``empty_response_action: fallback`` as well — that is the knob that
1280
+ # withholds the opening events from the client. Without it the opening
1281
+ # ``message_start`` has already shipped, so a truncation is by
1282
+ # definition mid-stream.
1283
+ stream_truncation_action: Literal["off", "warn", "error"] = Field(
1284
+ default="off",
1285
+ description=(
1286
+ "v2.15.0: action when an upstream SSE stream ends without its "
1287
+ "protocol terminator (no ``message_stop`` / no ``[DONE]`` and no "
1288
+ "``finish_reason``). ``off`` (default) does nothing — identical to "
1289
+ "v2.14.0. ``warn`` emits a ``stream-truncation-detected`` log and "
1290
+ "metric while the stream still ends through the legacy terminator "
1291
+ "synthesis. ``error`` raises a retryable ``StreamTruncatedError`` "
1292
+ "so the engine falls back to the next provider (reason "
1293
+ "``stream-truncated``) when nothing has reached the client yet, or "
1294
+ "raises ``MidStreamError`` when it has. Pair with "
1295
+ "``empty_response_action: fallback`` for pre-content fallback on "
1296
+ "the Anthropic streaming path."
1297
+ ),
1298
+ )
1299
+
1091
1300
  # --- ⑧ (empty-response): per-request empty-response fallback ------------
1092
1301
  #
1093
1302
  # Some local backends (observed: gemma4:26b on ``no_tool_temptation``
@@ -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