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
|
@@ -45,7 +45,7 @@ from coderouter.adapters.base import (
|
|
|
45
45
|
ProviderCallOverrides,
|
|
46
46
|
StreamChunk,
|
|
47
47
|
)
|
|
48
|
-
from coderouter.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
|
691
|
+
from coderouter.config.loader import resolve_config_path
|
|
611
692
|
|
|
612
|
-
|
|
613
|
-
if
|
|
614
|
-
|
|
615
|
-
|
|
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())
|
coderouter/config/loader.py
CHANGED
|
@@ -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
|
-
**
|
|
13
|
-
|
|
14
|
-
``
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
#
|
|
36
|
-
# (see module docstring)
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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})
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
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
|
-
|
|
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
|