coderouter-cli 2.13.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
 
@@ -969,5 +1042,70 @@ def _run_check_env(arg_value: str) -> int:
969
1042
  return exit_code_for_env_security(report)
970
1043
 
971
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
+
972
1110
  if __name__ == "__main__":
973
1111
  sys.exit(main())
@@ -34,6 +34,7 @@ import yaml
34
34
 
35
35
  from coderouter.config.schemas import CodeRouterConfig
36
36
  from coderouter.logging import get_logger
37
+ from coderouter.secret_redaction import register_config_secrets, register_secret
37
38
 
38
39
  logger = get_logger(__name__)
39
40
 
@@ -253,12 +254,34 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
253
254
  elif env_paid in {"0", "false", "no", "off"}:
254
255
  config.allow_paid = False
255
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
+
256
269
  return config
257
270
 
258
271
 
259
272
  def resolve_api_key(api_key_env: str | None) -> str | None:
260
- """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
+ """
261
282
  if not api_key_env:
262
283
  return None
263
284
  value = os.environ.get(api_key_env, "").strip()
285
+ if value:
286
+ register_secret(value, api_key_env)
264
287
  return value or None
@@ -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).