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.
@@ -45,7 +45,7 @@ from coderouter.adapters.base import (
45
45
  ProviderCallOverrides,
46
46
  StreamChunk,
47
47
  )
48
- from coderouter.config.loader import resolve_api_key
48
+ from coderouter.credentials import resolve_provider_credential
49
49
  from coderouter.logging import get_logger, log_output_filter_applied
50
50
  from coderouter.output_filters import OutputFilterChain
51
51
  from coderouter.translation.anthropic import (
@@ -134,7 +134,9 @@ class AnthropicAdapter(BaseAdapter):
134
134
  self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
135
135
  ),
136
136
  }
137
- api_key = resolve_api_key(self.config.api_key_env)
137
+ # v2.14.0: see openai_compat._headers — same resolver, so an
138
+ # Anthropic-protocol provider can also ride a CLI session token.
139
+ api_key = resolve_provider_credential(self.config)
138
140
  if api_key:
139
141
  headers["x-api-key"] = api_key
140
142
  # v0.4-D: forward the client's anthropic-beta header verbatim.
@@ -29,7 +29,7 @@ from coderouter.adapters.base import (
29
29
  ProviderCallOverrides,
30
30
  StreamChunk,
31
31
  )
32
- from coderouter.config.loader import resolve_api_key
32
+ from coderouter.credentials import resolve_provider_credential
33
33
  from coderouter.logging import (
34
34
  get_logger,
35
35
  log_capability_degraded,
@@ -105,7 +105,10 @@ class OpenAICompatAdapter(BaseAdapter):
105
105
  "Content-Type": "application/json",
106
106
  "User-Agent": "CodeRouter/0.1",
107
107
  }
108
- api_key = resolve_api_key(self.config.api_key_env)
108
+ # v2.14.0: resolve_provider_credential covers api_key_env (the
109
+ # historical path, unchanged) and credential.source=cli_session,
110
+ # which borrows the token a vendor CLI already wrote to disk.
111
+ api_key = resolve_provider_credential(self.config)
109
112
  if api_key:
110
113
  headers["Authorization"] = f"Bearer {api_key}"
111
114
  return headers
coderouter/cli.py CHANGED
@@ -149,6 +149,22 @@ def _build_parser() -> argparse.ArgumentParser:
149
149
  "See docs/guides/troubleshooting.md §5 for the threat model."
150
150
  ),
151
151
  )
152
+ # v2.14.0: --check-secrets is the runtime counterpart to --check-env.
153
+ # --check-env asks "is the file holding my keys protected?"; this asks
154
+ # "does the running process know its own secrets, and did any of them
155
+ # already reach a log file?". The second question is the one that used
156
+ # to have no answer at all.
157
+ doctor.add_argument(
158
+ "--check-secrets",
159
+ action="store_true",
160
+ help=(
161
+ "Audit credential hygiene: prove the log-redaction filter "
162
+ "actually scrubs, report which declared api_key_env vars are "
163
+ "set, flag credentials pasted into a base_url, and scan the "
164
+ "already-written requests.jsonl / audit.jsonl under state_dir "
165
+ "for live key values. Exit 0 clean / 2 needs attention / 1 leak."
166
+ ),
167
+ )
152
168
  doctor.add_argument(
153
169
  "--config",
154
170
  default=None,
@@ -192,6 +208,55 @@ def _build_parser() -> argparse.ArgumentParser:
192
208
  ),
193
209
  )
194
210
 
211
+ # v2.14.0: `coderouter rollback` — the missing half of --apply.
212
+ # Both writers (doctor --apply, vscode-init) already dropped a `.bak`
213
+ # next to every file they rewrote; nothing could put it back. Restore
214
+ # is a swap, not an overwrite, so running it twice returns you to
215
+ # where you started rather than destroying the newer version.
216
+ rollback = sub.add_parser(
217
+ "rollback",
218
+ help="Restore files a previous --apply / vscode-init rewrote (v2.14.0).",
219
+ description=(
220
+ "Swap each managed file with its .bak sibling: providers.yaml, "
221
+ "~/.coderouter/model-capabilities.yaml, and (with --workspace) "
222
+ ".vscode/settings.json and .envrc. The current contents become "
223
+ "the new .bak, so a second run toggles back. "
224
+ "Exit codes: 0 restored, 2 nothing to restore, 1 a restore failed."
225
+ ),
226
+ )
227
+ rollback.add_argument(
228
+ "--config",
229
+ default=None,
230
+ help=(
231
+ "Path to providers.yaml. Defaults to the same file the loader "
232
+ "would read ($CODEROUTER_CONFIG, then ~/.coderouter/providers.yaml)."
233
+ ),
234
+ )
235
+ rollback.add_argument(
236
+ "--workspace",
237
+ metavar="DIR",
238
+ default=None,
239
+ help=(
240
+ "Also restore the vscode-init outputs under DIR: "
241
+ "DIR/.vscode/settings.json and DIR/.envrc."
242
+ ),
243
+ )
244
+ rollback.add_argument(
245
+ "--path",
246
+ action="append",
247
+ metavar="FILE",
248
+ default=None,
249
+ help=(
250
+ "Restore exactly this file from its .bak sibling. Repeatable. "
251
+ "When given, the default discovery set is not used."
252
+ ),
253
+ )
254
+ rollback.add_argument(
255
+ "--dry-run",
256
+ action="store_true",
257
+ help="Report what would be restored without touching any file.",
258
+ )
259
+
195
260
  # v1.5-C: `coderouter stats` — live TUI over GET /metrics.json.
196
261
  # Lazy-imports ``curses`` inside the runner so the CLI boot stays
197
262
  # snappy and environments without curses (rare, but e.g. minimal
@@ -490,6 +555,9 @@ def main(argv: list[str] | None = None) -> int:
490
555
  if args.command == "doctor":
491
556
  return _run_doctor(args)
492
557
 
558
+ if args.command == "rollback":
559
+ return _run_rollback(args)
560
+
493
561
  if args.command == "stats":
494
562
  # v1.5-C: stats is intentionally a thin wrapper — all logic
495
563
  # (fetch, render, curses loop) lives in coderouter.cli_stats so
@@ -526,9 +594,11 @@ def _run_doctor(args: argparse.Namespace) -> int:
526
594
  worst-case of the two reports so CI guarding against both leak
527
595
  risks AND broken providers can use a single command.
528
596
  """
529
- if args.check_model is None and args.check_env is None:
597
+ check_secrets = bool(getattr(args, "check_secrets", False))
598
+ if args.check_model is None and args.check_env is None and not check_secrets:
530
599
  print(
531
- "doctor: provide --check-model PROVIDER and/or --check-env [PATH]",
600
+ "doctor: provide --check-model PROVIDER, --check-env [PATH], "
601
+ "and/or --check-secrets",
532
602
  file=sys.stderr,
533
603
  )
534
604
  return 1
@@ -541,6 +611,9 @@ def _run_doctor(args: argparse.Namespace) -> int:
541
611
  if args.check_env is not None:
542
612
  worst_exit = max(worst_exit, _run_check_env(args.check_env))
543
613
 
614
+ if check_secrets:
615
+ worst_exit = max(worst_exit, _run_check_secrets(args.config))
616
+
544
617
  if args.check_model is not None:
545
618
  worst_exit = max(worst_exit, _run_check_model(args))
546
619
 
@@ -600,28 +673,27 @@ def _run_check_model(args: argparse.Namespace) -> int:
600
673
 
601
674
 
602
675
  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.
676
+ """Return the file the loader actually read, for ``--apply`` write-back.
677
+
678
+ Delegates the search order to
679
+ :func:`coderouter.config.loader.resolve_config_path` rather than
680
+ re-implementing it. Re-implementing it here is exactly how the
681
+ v2.13.0 CWD opt-in change would introduce a silent bug: if the loader
682
+ gates ``./providers.yaml`` behind ``CODEROUTER_ALLOW_CWD_CONFIG`` but
683
+ this copy still searched CWD unconditionally, ``doctor --apply`` would
684
+ write patches back into a file that ``load_config`` never read. Keeping
685
+ one source of truth makes that impossible.
686
+
687
+ When nothing exists, falls back to ``~/.coderouter/providers.yaml``
688
+ (the loader's last candidate) — the apply step surfaces a clearer
689
+ error against that path than this resolver would.
609
690
  """
610
- import os
691
+ from coderouter.config.loader import resolve_config_path
611
692
 
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]
693
+ resolved = resolve_config_path(explicit)
694
+ if resolved is not None:
695
+ return resolved
696
+ return Path.home() / ".coderouter" / "providers.yaml"
625
697
 
626
698
 
627
699
  def _write_verdict_line(*, write: bool, files_written: int) -> None:
@@ -970,5 +1042,70 @@ def _run_check_env(arg_value: str) -> int:
970
1042
  return exit_code_for_env_security(report)
971
1043
 
972
1044
 
1045
+ def _run_rollback(args: argparse.Namespace) -> int:
1046
+ """v2.14.0: put back what ``--apply`` / ``vscode-init`` overwrote.
1047
+
1048
+ Explicit ``--path`` wins over discovery so an operator can restore one
1049
+ file without also reverting an unrelated ``model-capabilities.yaml``
1050
+ edit from last week. Discovery resolves providers.yaml through the
1051
+ loader's own search order rather than a second copy of it.
1052
+ """
1053
+ from coderouter.rollback import (
1054
+ discover_managed_files,
1055
+ exit_code_for_rollback,
1056
+ format_rollback_report,
1057
+ restore_many,
1058
+ )
1059
+
1060
+ explicit = getattr(args, "path", None)
1061
+ if explicit:
1062
+ targets: list[Path] = [Path(p).expanduser() for p in explicit]
1063
+ else:
1064
+ try:
1065
+ config_path: Path | None = _resolve_config_path(args.config)
1066
+ except Exception:
1067
+ # No config anywhere is not fatal here — the workspace files
1068
+ # and the user-layer capabilities file may still have backups.
1069
+ config_path = None
1070
+ targets = discover_managed_files(
1071
+ config_path=config_path,
1072
+ workspace=getattr(args, "workspace", None),
1073
+ )
1074
+
1075
+ outcomes = restore_many(targets, dry_run=bool(args.dry_run))
1076
+ print(format_rollback_report(outcomes, dry_run=bool(args.dry_run)))
1077
+ return exit_code_for_rollback(outcomes)
1078
+
1079
+
1080
+ def _run_check_secrets(config_path: str | None) -> int:
1081
+ """v2.14.0: credential-hygiene audit for the running configuration.
1082
+
1083
+ Loads the config through the normal loader — which is also what arms
1084
+ the redaction registry — then runs the suite from
1085
+ :mod:`coderouter.secret_redaction`. A config that fails to load is a
1086
+ blocker (exit 1) rather than a skip: we cannot claim anything about
1087
+ secret hygiene for a file we could not read.
1088
+ """
1089
+ from coderouter.config.loader import load_config
1090
+ from coderouter.secret_redaction import (
1091
+ check_secret_hygiene,
1092
+ exit_code_for_secret_report,
1093
+ format_secret_report,
1094
+ )
1095
+
1096
+ try:
1097
+ config = load_config(config_path)
1098
+ except FileNotFoundError as exc:
1099
+ print(f"doctor: {exc}", file=sys.stderr)
1100
+ return 1
1101
+ except Exception as exc:
1102
+ print(f"doctor: failed to load config: {exc}", file=sys.stderr)
1103
+ return 1
1104
+
1105
+ report = check_secret_hygiene(config)
1106
+ print(format_secret_report(report))
1107
+ return exit_code_for_secret_report(report)
1108
+
1109
+
973
1110
  if __name__ == "__main__":
974
1111
  sys.exit(main())
@@ -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
@@ -29,13 +34,29 @@ import yaml
29
34
 
30
35
  from coderouter.config.schemas import CodeRouterConfig
31
36
  from coderouter.logging import get_logger
37
+ from coderouter.secret_redaction import register_config_secrets, register_secret
32
38
 
33
39
  logger = get_logger(__name__)
34
40
 
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.
41
+ # Name of the env var that opts CWD ``providers.yaml`` discovery back in
42
+ # (see module docstring). v2.13.0 gates step 3 of the search behind it.
43
+ CWD_CONFIG_ENV = "CODEROUTER_ALLOW_CWD_CONFIG"
44
+ # Truthy vocabulary shared by every CWD-opt-in / ALLOW_PAID style toggle.
45
+ _TRUTHY = {"1", "true", "yes", "on"}
46
+
47
+ # Guards the one-time ``cwd-config-loaded`` warning (opt-in enabled and the
48
+ # CWD step actually resolved) so a process that calls load_config()
49
+ # repeatedly (tests, hot-reload, multiple create_app() calls) only logs it
50
+ # once.
38
51
  _cwd_config_warning_emitted = False
52
+ # Guards the one-time ``cwd-config-skipped`` warning (opt-in NOT set but a
53
+ # ./providers.yaml exists and was ignored) — same once-per-process rationale.
54
+ _cwd_config_skip_warning_emitted = False
55
+
56
+
57
+ def cwd_config_allowed() -> bool:
58
+ """True iff the CWD ``providers.yaml`` opt-in is enabled (v2.13.0)."""
59
+ return os.environ.get(CWD_CONFIG_ENV, "").strip().lower() in _TRUTHY
39
60
 
40
61
 
41
62
  def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
@@ -44,27 +65,58 @@ def _candidate_paths(explicit: str | os.PathLike[str] | None) -> list[Path]:
44
65
  paths.append(Path(explicit))
45
66
  if env_path := os.environ.get("CODEROUTER_CONFIG"):
46
67
  paths.append(Path(env_path))
47
- paths.append(Path.cwd() / "providers.yaml")
68
+ # v2.13.0: implicit CWD discovery is opt-in — a hostile providers.yaml
69
+ # dropped into a repo could otherwise steer restart_command / launcher
70
+ # binaries simply because CodeRouter was started from that directory.
71
+ if cwd_config_allowed():
72
+ paths.append(Path.cwd() / "providers.yaml")
48
73
  paths.append(Path.home() / ".coderouter" / "providers.yaml")
49
74
  return paths
50
75
 
51
76
 
77
+ def resolve_config_path(
78
+ explicit: str | os.PathLike[str] | None = None,
79
+ ) -> Path | None:
80
+ """Return the config file the loader would pick, or None if none exists.
81
+
82
+ Single source of truth for the search order so callers (e.g.
83
+ ``coderouter.cli._resolve_config_path`` driving ``doctor --apply``)
84
+ never re-implement it. Re-implementing it risks writing back to a
85
+ different file than the one :func:`load_config` actually read — the
86
+ exact regression the v2.13.0 CWD opt-in would otherwise introduce.
87
+ """
88
+ return next((p for p in _candidate_paths(explicit) if p.is_file()), None)
89
+
90
+
91
+ def _explicitly_named(
92
+ path: Path, explicit: str | os.PathLike[str] | None
93
+ ) -> bool:
94
+ """True iff ``path`` matches the literal --config or CODEROUTER_CONFIG value.
95
+
96
+ Used to keep the CWD warnings quiet when ``./providers.yaml`` was
97
+ chosen on purpose (an explicit choice, however coincidental, is not
98
+ the implicit-discovery behaviour being gated).
99
+ """
100
+ if explicit and Path(explicit) == path:
101
+ return True
102
+ env_path = os.environ.get("CODEROUTER_CONFIG")
103
+ return env_path is not None and Path(env_path) == path
104
+
105
+
52
106
  def _warn_if_cwd_config(
53
107
  chosen: Path, *, explicit: str | os.PathLike[str] | None
54
108
  ) -> None:
55
- """Emit the one-time [Unreleased]/v2.12.0 CWD-config deprecation warning.
109
+ """Emit the one-time ``cwd-config-loaded`` warning (opt-in is enabled).
56
110
 
57
111
  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.
112
+ step — which, since v2.13.0, only runs when
113
+ ``CODEROUTER_ALLOW_CWD_CONFIG`` is set. Compares ``chosen`` against the
114
+ *literal* explicit / env candidate paths (not merely whether those
115
+ inputs were set) so that an explicit ``--config`` (or
116
+ ``CODEROUTER_CONFIG``) path that happens to coincide with
117
+ ``./providers.yaml`` stays quiet, while an explicit path/env var that
118
+ was set but did NOT resolve (file missing, search fell through to CWD)
119
+ still warns — the CWD step is what actually served the config there.
68
120
  """
69
121
  global _cwd_config_warning_emitted
70
122
  if _cwd_config_warning_emitted:
@@ -72,9 +124,7 @@ def _warn_if_cwd_config(
72
124
  cwd_path = Path.cwd() / "providers.yaml"
73
125
  if chosen != cwd_path:
74
126
  return
75
- if explicit and Path(explicit) == chosen:
76
- return
77
- if (env_path := os.environ.get("CODEROUTER_CONFIG")) and Path(env_path) == chosen:
127
+ if _explicitly_named(chosen, explicit):
78
128
  return
79
129
 
80
130
  _cwd_config_warning_emitted = True
@@ -84,26 +134,76 @@ def _warn_if_cwd_config(
84
134
  "path": str(chosen),
85
135
  "hint": (
86
136
  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."
137
+ f"directory ({chosen}) because CODEROUTER_ALLOW_CWD_CONFIG "
138
+ "opt-in is enabled. This file's restart_command / "
139
+ "launcher.backends[*].binary / "
140
+ "launcher.bench.command_template decide which executables "
141
+ "run — only enable the opt-in in directories you trust."
92
142
  ),
93
143
  },
94
144
  )
95
145
 
96
146
 
147
+ def _warn_if_cwd_config_skipped(
148
+ explicit: str | os.PathLike[str] | None,
149
+ ) -> Path | None:
150
+ """Warn once when a ./providers.yaml exists but was skipped (no opt-in).
151
+
152
+ Returns the skipped CWD path (so :func:`load_config` can name it in a
153
+ later ``FileNotFoundError``), or None when there is nothing to skip:
154
+ the opt-in is enabled, no ``./providers.yaml`` exists, or the file was
155
+ explicitly named (``--config`` / ``CODEROUTER_CONFIG``) — in which case
156
+ it is not being skipped at all.
157
+ """
158
+ global _cwd_config_skip_warning_emitted
159
+ if cwd_config_allowed():
160
+ return None
161
+ cwd_path = Path.cwd() / "providers.yaml"
162
+ if not cwd_path.is_file():
163
+ return None
164
+ if _explicitly_named(cwd_path, explicit):
165
+ return None
166
+
167
+ if not _cwd_config_skip_warning_emitted:
168
+ _cwd_config_skip_warning_emitted = True
169
+ logger.warning(
170
+ "cwd-config-skipped",
171
+ extra={
172
+ "path": str(cwd_path),
173
+ "hint": (
174
+ f"found providers.yaml in the current working "
175
+ f"directory ({cwd_path}) but did NOT load it: implicit "
176
+ "CWD discovery is opt-in since v2.13.0 (a hostile "
177
+ "providers.yaml can steer restart_command / launcher "
178
+ "binaries). To use it, set CODEROUTER_ALLOW_CWD_CONFIG=1 "
179
+ "(only in directories you trust) or pass --config "
180
+ f"{cwd_path} to name it explicitly."
181
+ ),
182
+ },
183
+ )
184
+ return cwd_path
185
+
186
+
97
187
  def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
98
188
  """Load providers.yaml + apply ALLOW_PAID env override."""
189
+ skipped_cwd = _warn_if_cwd_config_skipped(path)
99
190
  candidates = _candidate_paths(path)
100
191
  chosen: Path | None = next((p for p in candidates if p.is_file()), None)
101
192
  if chosen is None:
102
193
  searched = "\n ".join(str(p) for p in candidates)
103
- raise FileNotFoundError(
194
+ message = (
104
195
  f"providers.yaml not found. Searched:\n {searched}\n"
105
196
  f"Hint: copy examples/providers.yaml to ~/.coderouter/providers.yaml"
106
197
  )
198
+ if skipped_cwd is not None:
199
+ message += (
200
+ f"\nNote: {skipped_cwd} exists but was NOT read — implicit "
201
+ "CWD discovery is opt-in since v2.13.0. Three ways to use "
202
+ f"it: pass --config {skipped_cwd}, set "
203
+ f"CODEROUTER_CONFIG={skipped_cwd}, or set "
204
+ "CODEROUTER_ALLOW_CWD_CONFIG=1 (only in directories you trust)."
205
+ )
206
+ raise FileNotFoundError(message)
107
207
  _warn_if_cwd_config(chosen, explicit=path)
108
208
 
109
209
  with chosen.open("r", encoding="utf-8") as f:
@@ -154,12 +254,34 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
154
254
  elif env_paid in {"0", "false", "no", "off"}:
155
255
  config.allow_paid = False
156
256
 
257
+ # v2.14.0: arm log redaction before anything else runs. Registering here
258
+ # (rather than lazily on the first resolve_api_key call) means the
259
+ # startup log lines — which name providers and profiles — are already
260
+ # scrubbed, and so is any config-validation error raised downstream.
261
+ # Only labels are logged; the values never leave the registry.
262
+ registered = register_config_secrets(config)
263
+ if registered:
264
+ logger.info(
265
+ "secret-redaction-armed",
266
+ extra={"count": len(registered), "labels": sorted(set(registered))},
267
+ )
268
+
157
269
  return config
158
270
 
159
271
 
160
272
  def resolve_api_key(api_key_env: str | None) -> str | None:
161
- """Look up an API key from the named env var. Returns None if unset."""
273
+ """Look up an API key from the named env var. Returns None if unset.
274
+
275
+ v2.14.0: the resolved value is registered with
276
+ :mod:`coderouter.secret_redaction` before it is handed out. This is the
277
+ moment a credential enters the process, so it is the one place that can
278
+ guarantee the log scrubber knows about every key we actually use —
279
+ including keys for providers added by an adapter plugin, which never
280
+ pass through ``register_config_secrets``.
281
+ """
162
282
  if not api_key_env:
163
283
  return None
164
284
  value = os.environ.get(api_key_env, "").strip()
285
+ if value:
286
+ register_secret(value, api_key_env)
165
287
  return value or None