coderouter-cli 2.11.1__py3-none-any.whl → 2.11.2__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/vscode_init.py CHANGED
@@ -16,11 +16,21 @@ The design contract:
16
16
  without ``--force`` and print a unified diff so the operator decides.
17
17
  * ``--dry-run`` is byte-identical to the write path minus the actual
18
18
  ``os.replace`` at the end.
19
- * All writes are atomic (tmp file + ``os.replace``) so a partial write
20
- cannot corrupt an existing ``settings.json``. This matters more here
21
- than in most CodeRouter modules because the file we're editing is
22
- workspace-critical: a broken JSON blob would silently break every
23
- VSCode terminal env until a human debugged it.
19
+ * All writes are atomic (``tempfile.mkstemp`` + ``fsync`` +
20
+ ``os.replace``) so a partial write cannot corrupt an existing
21
+ ``settings.json``. This matters more here than in most CodeRouter
22
+ modules because the file we're editing is workspace-critical: a
23
+ broken JSON blob would silently break every VSCode terminal env
24
+ until a human debugged it.
25
+ * Nothing an operator wrote is destroyed without a copy: before any
26
+ existing file is rewritten, its previous bytes (and mode) land in
27
+ ``<name>.bak``. ``--force`` on ``.envrc`` is a whole-file replace,
28
+ not a merge, so this is the only thing standing between the user and
29
+ a lost ``source_env_if_exists .envrc.local`` line.
30
+ * A freshly generated ``.envrc`` is created 0600 — it carries
31
+ ``ANTHROPIC_AUTH_TOKEN`` and sits next to the operator's real
32
+ secrets. An existing file's mode is preserved across rewrites
33
+ instead of being reset to the umask default by ``os.replace``.
24
34
 
25
35
  Files touched (opt-in per flag):
26
36
 
@@ -40,8 +50,12 @@ directly without argparse in the way.
40
50
 
41
51
  from __future__ import annotations
42
52
 
53
+ import contextlib
43
54
  import json
44
55
  import os
56
+ import shutil
57
+ import stat
58
+ import tempfile
45
59
  from dataclasses import dataclass
46
60
  from difflib import unified_diff
47
61
  from pathlib import Path
@@ -62,6 +76,15 @@ DEFAULT_PORT = 8088
62
76
  # then flag the unused directive.)
63
77
  DEFAULT_TOKEN = "dummy"
64
78
 
79
+ # v2.11 (H-11): a generated .envrc carries ANTHROPIC_AUTH_TOKEN, and
80
+ # users routinely add their real key next to it (or point
81
+ # ``source_env_if_exists .envrc.local`` at one). ``env_security``
82
+ # already WARNs on any ``.env`` whose mode has 0o077 bits set and tells
83
+ # the operator to ``chmod 0600``; a file we generate ourselves should
84
+ # not be born failing that check. Applies to newly created files only —
85
+ # an existing file keeps whatever mode its owner chose.
86
+ _ENVRC_MODE = 0o600
87
+
65
88
  # VSCode reads terminal env from a separate key per host OS. Writing all
66
89
  # three means the same ``.vscode/`` folder works whether the developer
67
90
  # opens it on macOS, Linux, or WSL/Windows without a second scaffolder
@@ -261,7 +284,9 @@ def _apply_settings_json(
261
284
  diff=diff,
262
285
  reason=(
263
286
  "existing settings.json has different value(s) for "
264
- f"{', '.join(sorted(conflicts))}. Re-run with --force to overwrite."
287
+ f"{', '.join(sorted(conflicts))}. Re-run with --force to "
288
+ "overwrite those keys (unrelated keys are preserved; the "
289
+ f"previous file is saved to {_backup_path(path).name})."
265
290
  ),
266
291
  )
267
292
 
@@ -270,7 +295,24 @@ def _apply_settings_json(
270
295
  return FileOutcome(path=path, action="unchanged")
271
296
 
272
297
  diff = _text_diff(old_text, new_text, path)
298
+ reason = ""
273
299
  if not dry_run:
300
+ # H-11: same deal as .envrc. The merge preserves unrelated keys
301
+ # by design, but a bug in _merge_terminal_env would rewrite a
302
+ # hand-tuned settings.json with no way back — so keep the old
303
+ # bytes. Unlike .envrc this is not gated on --force: an additive
304
+ # merge rewrites the file without --force too, and that is
305
+ # exactly the path a merge bug would take.
306
+ if old_text:
307
+ backup = _make_backup(path)
308
+ reason = (
309
+ f"previous contents backed up to {backup}"
310
+ if backup is not None
311
+ else (
312
+ f"could not write {_backup_path(path).name} — "
313
+ "previous contents were NOT preserved"
314
+ )
315
+ )
274
316
  try:
275
317
  path.parent.mkdir(parents=True, exist_ok=True)
276
318
  _atomic_write(path, new_text)
@@ -281,11 +323,14 @@ def _apply_settings_json(
281
323
  reason=f"write failed: {exc}",
282
324
  diff=diff,
283
325
  )
326
+ elif old_text:
327
+ reason = f"would back up previous contents to {_backup_path(path)}"
284
328
 
285
329
  return FileOutcome(
286
330
  path=path,
287
331
  action="updated" if old_text else "created",
288
332
  diff=diff,
333
+ reason=reason,
289
334
  )
290
335
 
291
336
 
@@ -374,15 +419,32 @@ def _apply_envrc(
374
419
  diff=diff,
375
420
  reason=(
376
421
  ".envrc already exists and its contents differ. "
377
- "Re-run with --force to overwrite. "
422
+ "Re-run with --force to replace the WHOLE file with the "
423
+ "generated contents (this is not a merge — custom lines "
424
+ "such as `source_env_if_exists .envrc.local` are dropped); "
425
+ f"the previous contents are saved to {_backup_path(path).name}. "
378
426
  "(Tip: keep secrets in a separate .envrc.local and "
379
427
  "``source_env_if_exists`` from .envrc.)"
380
428
  ),
381
429
  )
382
430
 
431
+ reason = ""
383
432
  if not dry_run:
433
+ # H-11: --force replaces the whole file, so stash the old bytes
434
+ # (mode included) before they are gone. Inside the dry_run guard
435
+ # on purpose — --dry-run must not touch the filesystem at all.
436
+ if old_text:
437
+ backup = _make_backup(path)
438
+ reason = (
439
+ f"previous contents backed up to {backup}"
440
+ if backup is not None
441
+ else (
442
+ f"could not write {_backup_path(path).name} — "
443
+ "previous contents were NOT preserved"
444
+ )
445
+ )
384
446
  try:
385
- _atomic_write(path, new_text)
447
+ _atomic_write(path, new_text, mode=_ENVRC_MODE)
386
448
  except OSError as exc:
387
449
  return FileOutcome(
388
450
  path=path,
@@ -390,11 +452,14 @@ def _apply_envrc(
390
452
  reason=f"write failed: {exc}",
391
453
  diff=diff,
392
454
  )
455
+ elif old_text:
456
+ reason = f"would back up previous contents to {_backup_path(path)}"
393
457
 
394
458
  return FileOutcome(
395
459
  path=path,
396
460
  action="updated" if old_text else "created",
397
461
  diff=diff,
462
+ reason=reason,
398
463
  )
399
464
 
400
465
 
@@ -538,21 +603,112 @@ def _dump_json(obj: object) -> str:
538
603
  return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
539
604
 
540
605
 
541
- def _atomic_write(path: Path, text: str) -> None:
542
- """Write ``text`` to ``path`` atomically (tmp + os.replace).
606
+ def _default_file_mode() -> int:
607
+ """Return the mode a plain ``open(..., "w")`` would produce.
608
+
609
+ ``tempfile.mkstemp`` deliberately creates its file 0600. That is the
610
+ right default for a secret-bearing ``.envrc``, but it would silently
611
+ tighten ``settings.json`` (previously umask-derived, typically
612
+ 0644). Reading the umask back keeps the pre-existing behaviour for
613
+ files that have no explicit mode requirement.
614
+ """
615
+ current = os.umask(0)
616
+ os.umask(current)
617
+ return 0o666 & ~current
618
+
619
+
620
+ def _atomic_write(path: Path, text: str, *, mode: int | None = None) -> None:
621
+ """Write ``text`` to ``path`` atomically (tmp + ``os.replace``).
543
622
 
544
623
  A partial write cannot corrupt the target file: the tmp file lives
545
624
  on the same filesystem (same parent directory) so ``os.replace`` is
546
625
  an atomic inode swap on POSIX. On Windows ``os.replace`` is also
547
626
  atomic since Python 3.3.
548
627
 
549
- The tmp file uses the parent + ``<name>.tmp`` pattern rather than
550
- ``Path.with_suffix`` because ``.envrc`` has no stem/suffix in the
551
- ``Path`` sense and ``with_suffix`` would silently misbehave.
628
+ v2.11 hardening (H-11). The tmp file used to be the predictable
629
+ ``<name>.tmp`` next to the target, written with ``Path.write_text``:
630
+
631
+ * a pre-planted symlink at that exact path was *followed*, so the
632
+ write landed on the link's target (CWE-377);
633
+ * two concurrent ``vscode-init`` runs on the same workspace
634
+ clobbered each other's tmp file;
635
+ * ``os.replace`` swaps inodes, so the target's mode was reset to the
636
+ umask default — a 0600 ``.envrc`` holding ``ANTHROPIC_AUTH_TOKEN``
637
+ silently became 0644;
638
+ * without ``fsync`` a crash right after the rename could leave a
639
+ zero-length file.
640
+
641
+ So: ``tempfile.mkstemp`` (random name, ``O_EXCL``, no symlink
642
+ following), ``fsync`` before the rename, mode restored from the
643
+ file being replaced (or from ``mode`` for a fresh file), and the tmp
644
+ file unlinked in ``finally`` so a failed write leaves no debris.
645
+
646
+ Parameters
647
+ ----------
648
+ mode:
649
+ POSIX mode for a *newly created* file. Ignored when ``path``
650
+ already exists — an existing file's own mode wins, so we never
651
+ loosen (or tighten) what the operator chose. ``None`` means
652
+ "whatever the umask would have given us".
653
+ """
654
+ existing_mode: int | None = None
655
+ try:
656
+ existing_mode = stat.S_IMODE(path.stat().st_mode)
657
+ except OSError:
658
+ existing_mode = None
659
+
660
+ target_mode = existing_mode if existing_mode is not None else mode
661
+ if target_mode is None:
662
+ target_mode = _default_file_mode()
663
+
664
+ fd, tmp_name = tempfile.mkstemp(
665
+ dir=str(path.parent),
666
+ prefix=f".{path.name}.",
667
+ suffix=".tmp",
668
+ )
669
+ tmp = Path(tmp_name)
670
+ try:
671
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
672
+ handle.write(text)
673
+ handle.flush()
674
+ os.fsync(handle.fileno())
675
+ # Windows has no POSIX mode bits worth honouring; os.chmod there
676
+ # only toggles the read-only flag, which would be a surprising
677
+ # side effect of "preserve the mode".
678
+ if os.name != "nt":
679
+ os.chmod(tmp, target_mode)
680
+ os.replace(tmp, path)
681
+ finally:
682
+ # Best effort: a leftover tmp file is noise, not corruption, and
683
+ # cleaning it up must never mask the original exception.
684
+ with contextlib.suppress(OSError):
685
+ tmp.unlink(missing_ok=True)
686
+
687
+
688
+ def _backup_path(path: Path) -> Path:
689
+ """Return the ``.bak`` sibling used to preserve ``path``'s old bytes."""
690
+ return path.with_name(path.name + ".bak")
691
+
692
+
693
+ def _make_backup(path: Path) -> Path | None:
694
+ """Copy ``path`` to ``<name>.bak``, preserving mode and mtime.
695
+
696
+ Returns the backup path, or ``None`` when the copy failed (the
697
+ caller then proceeds without a backup rather than refusing to write
698
+ — but the failure is not silent, see the ``reason`` plumbing).
699
+
700
+ The destination is unlinked first: ``shutil.copy2`` opens the
701
+ destination for writing and would happily follow a symlink planted
702
+ at ``<name>.bak``. ``Path.unlink`` removes the link itself, so the
703
+ subsequent copy always creates a fresh regular file.
552
704
  """
553
- tmp = path.parent / (path.name + ".tmp")
554
- tmp.write_text(text, encoding="utf-8")
555
- os.replace(tmp, path)
705
+ backup = _backup_path(path)
706
+ try:
707
+ backup.unlink(missing_ok=True)
708
+ shutil.copy2(path, backup)
709
+ except OSError:
710
+ return None
711
+ return backup
556
712
 
557
713
 
558
714
  def _text_diff(old: str, new: str, path: Path) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.11.1
3
+ Version: 2.11.2
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -17,7 +17,7 @@ coderouter/logging.py,sha256=5rfi4_R4aoeeUYDc642dgs61wA0aBX7wvubyB-k3Uyo,58074
17
17
  coderouter/output_filters.py,sha256=0ry_rPiS_kC-FnHgaNVP6v7e6Al2djxzu9vBzZ8kEkE,25314
18
18
  coderouter/token_estimation.py,sha256=iz22vZEEW2P7uKLB2pYvPNpIbZGbgXRO5MtfkS_-9Sk,7531
19
19
  coderouter/token_estimation_accurate.py,sha256=GTfzrBVnvAGjeVzmzAeUdOYZvWZKLAxcxPpFiJGlzjk,4609
20
- coderouter/vscode_init.py,sha256=ATQ1FzeQRAXfBBNcPwpEHRP4l4Yjn4hYgE-TjkcVvck,20034
20
+ coderouter/vscode_init.py,sha256=fW0chCDTngvWQuJgQ7aqYmE6hnZO3hCk8mAiEkZ9FCo,26741
21
21
  coderouter/adapters/__init__.py,sha256=7dIDSZ-FE_0iSqLSDc_lK1idRdLTKcM2hP9tCJipgPI,463
22
22
  coderouter/adapters/anthropic_native.py,sha256=-FGTQFlFL5Z2IZms4NUGPcNkIN-fVvQrCY8e8nxW17s,24777
23
23
  coderouter/adapters/base.py,sha256=ykKMaiIVVGr23oFFXilK2iP_YhI8-CtsEiJyFXaC9bE,10396
@@ -71,8 +71,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
71
71
  coderouter/translation/anthropic.py,sha256=ZQQGS9itN_3o7IKD68lKr5gR_Kem32Jsd7OcGjJzv8Q,13468
72
72
  coderouter/translation/convert.py,sha256=VrstDAMsUpcQ1VgMTn_-ETp1OjGX8v0ZGhCkcd4icyU,53037
73
73
  coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
74
- coderouter_cli-2.11.1.dist-info/METADATA,sha256=s5AYjjcq3k-9rNEZ4cNAf5arPT4FkS8bMuqVlNpBOc0,16897
75
- coderouter_cli-2.11.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
76
- coderouter_cli-2.11.1.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
77
- coderouter_cli-2.11.1.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
78
- coderouter_cli-2.11.1.dist-info/RECORD,,
74
+ coderouter_cli-2.11.2.dist-info/METADATA,sha256=zw-XK_yuV-v6dBPqAohL8iYdQJW_rmbI0UhmVvyfT0s,16897
75
+ coderouter_cli-2.11.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
76
+ coderouter_cli-2.11.2.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
77
+ coderouter_cli-2.11.2.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
78
+ coderouter_cli-2.11.2.dist-info/RECORD,,