coderouter-cli 2.11.0__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/adapters/anthropic_native.py +23 -5
- coderouter/cli.py +49 -0
- coderouter/config/loader.py +65 -0
- coderouter/config/schemas.py +85 -3
- coderouter/doctor.py +98 -19
- coderouter/doctor_apply.py +72 -3
- coderouter/gguf_introspect.py +132 -25
- coderouter/guards/drift_detection.py +13 -1
- coderouter/ingress/launcher_routes.py +3 -3
- coderouter/launcher_devices.py +21 -9
- coderouter/routing/fallback.py +14 -2
- coderouter/translation/anthropic.py +14 -1
- coderouter/translation/convert.py +7 -0
- coderouter/translation/tool_repair.py +193 -76
- coderouter/vscode_init.py +172 -16
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/METADATA +1 -1
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/RECORD +20 -20
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.11.0.dist-info → coderouter_cli-2.11.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -90,12 +90,28 @@ from __future__ import annotations
|
|
|
90
90
|
|
|
91
91
|
import contextlib
|
|
92
92
|
import json
|
|
93
|
+
import logging
|
|
93
94
|
import re
|
|
94
95
|
import uuid
|
|
96
|
+
from array import array
|
|
95
97
|
from typing import Any
|
|
96
98
|
|
|
97
99
|
__all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
|
|
98
100
|
|
|
101
|
+
# Plain stdlib logger (same idiom as coderouter/translation/anthropic.py).
|
|
102
|
+
# This module is deliberately import-free of the rest of the package:
|
|
103
|
+
# benchmarks/tool-repair/run_offline.py loads it straight from its source
|
|
104
|
+
# path, with no `coderouter` package on sys.path.
|
|
105
|
+
logger = logging.getLogger(__name__)
|
|
106
|
+
|
|
107
|
+
# Hard ceiling on the text handed to the bare-JSON brace scanners. The
|
|
108
|
+
# scanners themselves are linear, but every candidate they emit still costs a
|
|
109
|
+
# JSON parse attempt plus a prose-cue check, and an assistant message this
|
|
110
|
+
# large is never a tool call anyway. Above the limit the bare-JSON step is
|
|
111
|
+
# skipped outright; the fenced / XML / R4a / R4c paths are regex-driven and
|
|
112
|
+
# linear, so they keep running.
|
|
113
|
+
_MAX_BARE_SCAN_CHARS = 262_144
|
|
114
|
+
|
|
99
115
|
|
|
100
116
|
# ------------------------------------------------------------------
|
|
101
117
|
# R2: shape detection + normalisation (with key aliases)
|
|
@@ -528,6 +544,139 @@ def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
|
|
|
528
544
|
return any(start <= pos < end for start, end in spans)
|
|
529
545
|
|
|
530
546
|
|
|
547
|
+
# ------------------------------------------------------------------
|
|
548
|
+
# Brace scanning (bare-JSON candidate discovery)
|
|
549
|
+
#
|
|
550
|
+
# Both scanners below answer the same question for every ``{`` in the text:
|
|
551
|
+
# "does a *fresh* scan started right here reach a balanced close?". The
|
|
552
|
+
# obvious implementation re-scans to end-of-text once per ``{``, which is
|
|
553
|
+
# O(k*n) for k unclosed braces — a 48 KB assistant message full of stray
|
|
554
|
+
# braces took ~20 s, and because ``to_anthropic_response`` is called from the
|
|
555
|
+
# request path that stalled the whole server.
|
|
556
|
+
#
|
|
557
|
+
# Instead we precompute, in ONE right-to-left sweep, a table that answers
|
|
558
|
+
# "starting at offset j outside any string, where is the first ``}`` that is
|
|
559
|
+
# still at brace depth 0?". The closing brace of a ``{`` at offset q is then
|
|
560
|
+
# simply ``table[q + 1]``, so the driver below is a plain left-to-right walk
|
|
561
|
+
# and the whole scan is linear in the length of the text.
|
|
562
|
+
#
|
|
563
|
+
# The table is defined by a right-to-left recurrence over the scanner's state
|
|
564
|
+
# machine (outside a string / inside a string / previous char was a
|
|
565
|
+
# backslash). Only the "outside a string" row needs random access — the ``{``
|
|
566
|
+
# case has to jump over the nested object it just resolved — so the
|
|
567
|
+
# in-string rows are carried as rolling scalars instead of arrays.
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _scan_object_spans(text: str, close_table: array[int]) -> list[tuple[int, int, str]]:
|
|
572
|
+
"""Walk ``text`` left to right emitting balanced ``{...}`` spans.
|
|
573
|
+
|
|
574
|
+
``close_table`` comes from :func:`_strict_close_table` or
|
|
575
|
+
:func:`_lenient_close_table` and supplies, for each offset, the index of
|
|
576
|
+
the first depth-0 ``}`` at or after it (``-1`` when there is none). The
|
|
577
|
+
driver is shared; the flavour lives entirely in the table.
|
|
578
|
+
|
|
579
|
+
Returns (start, end_exclusive, substring) triples. See the two callers'
|
|
580
|
+
docstrings for the (deliberate, load-bearing) return-value contract.
|
|
581
|
+
"""
|
|
582
|
+
out: list[tuple[int, int, str]] = []
|
|
583
|
+
n = len(text)
|
|
584
|
+
i = 0
|
|
585
|
+
while i < n:
|
|
586
|
+
start = text.find("{", i)
|
|
587
|
+
if start < 0:
|
|
588
|
+
break
|
|
589
|
+
close = close_table[start + 1]
|
|
590
|
+
if close < 0:
|
|
591
|
+
# Never closes — drop this `{` and resume at the next one. The
|
|
592
|
+
# restart deliberately begins from a clean "outside a string"
|
|
593
|
+
# state (see the contract note in the callers).
|
|
594
|
+
i = start + 1
|
|
595
|
+
continue
|
|
596
|
+
out.append((start, close + 1, text[start : close + 1]))
|
|
597
|
+
i = close + 1
|
|
598
|
+
return out
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _strict_close_table(text: str) -> array[int]:
|
|
602
|
+
"""Depth-0 close-brace lookup table with *strict* JSON string rules.
|
|
603
|
+
|
|
604
|
+
``table[j]`` is the offset of the first ``}`` that a scanner starting at
|
|
605
|
+
``j`` — outside any string literal, at brace depth 0 — would reach while
|
|
606
|
+
still at depth 0, or ``-1`` if it runs off the end. Only ``"`` opens a
|
|
607
|
+
string; ``\\`` escapes the next character inside one.
|
|
608
|
+
|
|
609
|
+
Built in a single right-to-left sweep, so building plus scanning is
|
|
610
|
+
O(len(text)) regardless of how many unbalanced braces the text contains.
|
|
611
|
+
"""
|
|
612
|
+
n = len(text)
|
|
613
|
+
outside = array("i", [-1]) * (n + 1)
|
|
614
|
+
in_str = -1 # answer when the scan *enters* offset j+1 inside a "..."
|
|
615
|
+
escaped = -1 # ... inside a "..." with the previous char a backslash
|
|
616
|
+
for j in range(n - 1, -1, -1):
|
|
617
|
+
c = text[j]
|
|
618
|
+
nxt_outside = outside[j + 1]
|
|
619
|
+
nxt_in_str = in_str
|
|
620
|
+
nxt_escaped = escaped
|
|
621
|
+
# Escaped char: consumed verbatim, then we are back inside the string.
|
|
622
|
+
escaped = nxt_in_str
|
|
623
|
+
# Inside a string literal.
|
|
624
|
+
if c == "\\":
|
|
625
|
+
in_str = nxt_escaped
|
|
626
|
+
elif c == '"':
|
|
627
|
+
in_str = nxt_outside
|
|
628
|
+
else:
|
|
629
|
+
in_str = nxt_in_str
|
|
630
|
+
# Outside any string literal.
|
|
631
|
+
if c == '"':
|
|
632
|
+
outside[j] = nxt_in_str
|
|
633
|
+
elif c == "{":
|
|
634
|
+
inner = nxt_outside # close of the nested object opening at j
|
|
635
|
+
outside[j] = -1 if inner < 0 else outside[inner + 1]
|
|
636
|
+
elif c == "}":
|
|
637
|
+
outside[j] = j
|
|
638
|
+
else:
|
|
639
|
+
outside[j] = nxt_outside
|
|
640
|
+
return outside
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _lenient_close_table(text: str) -> array[int]:
|
|
644
|
+
"""Depth-0 close-brace lookup table tolerating single-quoted strings.
|
|
645
|
+
|
|
646
|
+
Same contract as :func:`_strict_close_table`, but BOTH ``'`` and ``"``
|
|
647
|
+
open a string literal (and only the matching quote closes it), which is
|
|
648
|
+
what the lenient pass needs to survive Python-repr / single-quoted
|
|
649
|
+
malformed objects. Five scanner states instead of three; again only the
|
|
650
|
+
"outside a string" row needs to be materialised as an array.
|
|
651
|
+
"""
|
|
652
|
+
n = len(text)
|
|
653
|
+
outside = array("i", [-1]) * (n + 1)
|
|
654
|
+
in_sq = in_dq = esc_sq = esc_dq = -1
|
|
655
|
+
for j in range(n - 1, -1, -1):
|
|
656
|
+
c = text[j]
|
|
657
|
+
nxt_outside = outside[j + 1]
|
|
658
|
+
nxt_in_sq, nxt_in_dq = in_sq, in_dq
|
|
659
|
+
nxt_esc_sq, nxt_esc_dq = esc_sq, esc_dq
|
|
660
|
+
esc_sq, esc_dq = nxt_in_sq, nxt_in_dq
|
|
661
|
+
if c == "\\":
|
|
662
|
+
in_sq, in_dq = nxt_esc_sq, nxt_esc_dq
|
|
663
|
+
else:
|
|
664
|
+
in_sq = nxt_outside if c == "'" else nxt_in_sq
|
|
665
|
+
in_dq = nxt_outside if c == '"' else nxt_in_dq
|
|
666
|
+
if c == "'":
|
|
667
|
+
outside[j] = nxt_in_sq
|
|
668
|
+
elif c == '"':
|
|
669
|
+
outside[j] = nxt_in_dq
|
|
670
|
+
elif c == "{":
|
|
671
|
+
inner = nxt_outside
|
|
672
|
+
outside[j] = -1 if inner < 0 else outside[inner + 1]
|
|
673
|
+
elif c == "}":
|
|
674
|
+
outside[j] = j
|
|
675
|
+
else:
|
|
676
|
+
outside[j] = nxt_outside
|
|
677
|
+
return outside
|
|
678
|
+
|
|
679
|
+
|
|
531
680
|
def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
532
681
|
"""Find top-level `{...}` JSON substrings by a brace-counter scan.
|
|
533
682
|
|
|
@@ -539,45 +688,27 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
|
539
688
|
brace balancing. Single-quoted / unquoted malformed objects are found by
|
|
540
689
|
:func:`_find_candidate_object_spans` instead, which brace-balances without
|
|
541
690
|
assuming JSON string syntax.
|
|
691
|
+
|
|
692
|
+
Return-value contract (load-bearing — downstream recall depends on all
|
|
693
|
+
three; do NOT "simplify" this into a single global stack pass):
|
|
694
|
+
|
|
695
|
+
1. **Top-level objects only.** Once a ``{`` closes, the scan resumes after
|
|
696
|
+
its ``}``, so objects nested inside a *closed* object are not returned.
|
|
697
|
+
``'{"a": {"b": 1}}'`` yields the outer object alone.
|
|
698
|
+
2. **Nested objects surface when the outer never closes.** An unbalanced
|
|
699
|
+
``{`` is dropped and the scan restarts at the next ``{``, so
|
|
700
|
+
``'{"a": {"b":1}'`` yields ``[(6, 13, '{"b":1}')]`` — the inner
|
|
701
|
+
object is still repairable even though the outer one is broken.
|
|
702
|
+
3. **Every restart begins with a fresh string state.** The scan between
|
|
703
|
+
candidates does not track quotes, so a stray/unbalanced quote before a
|
|
704
|
+
``{`` cannot swallow it. This is what keeps prose apostrophes
|
|
705
|
+
(``"I'll call the tool now."``) and quoted braces (``'{ "z{}" '`` ->
|
|
706
|
+
``[(4, 6, '{}')]``) from hiding a real call. A single global pass that
|
|
707
|
+
carried string state across candidates loses both.
|
|
542
708
|
"""
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
while i < n:
|
|
547
|
-
if text[i] != "{":
|
|
548
|
-
i += 1
|
|
549
|
-
continue
|
|
550
|
-
# Scan forward to find a balanced close.
|
|
551
|
-
depth = 0
|
|
552
|
-
j = i
|
|
553
|
-
in_str = False
|
|
554
|
-
escape = False
|
|
555
|
-
while j < n:
|
|
556
|
-
c = text[j]
|
|
557
|
-
if escape:
|
|
558
|
-
escape = False
|
|
559
|
-
elif in_str:
|
|
560
|
-
if c == "\\":
|
|
561
|
-
escape = True
|
|
562
|
-
elif c == '"':
|
|
563
|
-
in_str = False
|
|
564
|
-
else:
|
|
565
|
-
if c == '"':
|
|
566
|
-
in_str = True
|
|
567
|
-
elif c == "{":
|
|
568
|
-
depth += 1
|
|
569
|
-
elif c == "}":
|
|
570
|
-
depth -= 1
|
|
571
|
-
if depth == 0:
|
|
572
|
-
out.append((i, j + 1, text[i : j + 1]))
|
|
573
|
-
i = j + 1
|
|
574
|
-
break
|
|
575
|
-
j += 1
|
|
576
|
-
else:
|
|
577
|
-
# Ran off the end without closing — skip this `{` and move on.
|
|
578
|
-
i += 1
|
|
579
|
-
continue
|
|
580
|
-
return out
|
|
709
|
+
if "{" not in text:
|
|
710
|
+
return []
|
|
711
|
+
return _scan_object_spans(text, _strict_close_table(text))
|
|
581
712
|
|
|
582
713
|
|
|
583
714
|
def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
|
|
@@ -587,43 +718,17 @@ def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
|
|
|
587
718
|
respecting BOTH ``'`` and ``"`` string delimiters, so it can locate
|
|
588
719
|
Python-repr / single-quoted malformed objects for the lenient parser.
|
|
589
720
|
Returns (start, end_exclusive, substring).
|
|
721
|
+
|
|
722
|
+
The same three-point return-value contract documented on
|
|
723
|
+
:func:`_find_balanced_json_objects` applies here: top-level objects only,
|
|
724
|
+
nested objects surface when the outer never closes, and every restart
|
|
725
|
+
begins from a fresh string state. Point 3 matters most on this flavour —
|
|
726
|
+
``'`` opens a string here, so carrying state across candidates would let
|
|
727
|
+
an ordinary prose apostrophe swallow every following object.
|
|
590
728
|
"""
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
while i < n:
|
|
595
|
-
if text[i] != "{":
|
|
596
|
-
i += 1
|
|
597
|
-
continue
|
|
598
|
-
depth = 0
|
|
599
|
-
j = i
|
|
600
|
-
quote: str | None = None
|
|
601
|
-
escape = False
|
|
602
|
-
while j < n:
|
|
603
|
-
c = text[j]
|
|
604
|
-
if escape:
|
|
605
|
-
escape = False
|
|
606
|
-
elif quote is not None:
|
|
607
|
-
if c == "\\":
|
|
608
|
-
escape = True
|
|
609
|
-
elif c == quote:
|
|
610
|
-
quote = None
|
|
611
|
-
else:
|
|
612
|
-
if c == '"' or c == "'":
|
|
613
|
-
quote = c
|
|
614
|
-
elif c == "{":
|
|
615
|
-
depth += 1
|
|
616
|
-
elif c == "}":
|
|
617
|
-
depth -= 1
|
|
618
|
-
if depth == 0:
|
|
619
|
-
out.append((i, j + 1, text[i : j + 1]))
|
|
620
|
-
i = j + 1
|
|
621
|
-
break
|
|
622
|
-
j += 1
|
|
623
|
-
else:
|
|
624
|
-
i += 1
|
|
625
|
-
continue
|
|
626
|
-
return out
|
|
729
|
+
if "{" not in text:
|
|
730
|
+
return []
|
|
731
|
+
return _scan_object_spans(text, _lenient_close_table(text))
|
|
627
732
|
|
|
628
733
|
|
|
629
734
|
# ------------------------------------------------------------------
|
|
@@ -1405,12 +1510,24 @@ def repair_tool_calls_in_text(
|
|
|
1405
1510
|
# Skip anything inside a protected code fence or introduced by a prose
|
|
1406
1511
|
# example cue. Walk back-to-front so slicing removals don't shift the
|
|
1407
1512
|
# indices of earlier matches.
|
|
1513
|
+
# Oversized inputs skip this step entirely (see _MAX_BARE_SCAN_CHARS):
|
|
1514
|
+
# the per-candidate JSON parsing / cue checking is the expensive part
|
|
1515
|
+
# and a message that large is not a tool call. Steps 1/2/2b/2c above
|
|
1516
|
+
# are regex-driven and linear, so they are NOT skipped.
|
|
1408
1517
|
protected = _protected_code_spans(cleaned)
|
|
1409
1518
|
spans_to_remove: list[tuple[int, int]] = []
|
|
1410
1519
|
repaired_from_bare: list[dict[str, Any]] = []
|
|
1411
1520
|
|
|
1521
|
+
strict_spans: list[tuple[int, int, str]] = []
|
|
1522
|
+
lenient_spans: list[tuple[int, int, str]] = []
|
|
1523
|
+
if len(cleaned) > _MAX_BARE_SCAN_CHARS:
|
|
1524
|
+
logger.warning("tool-repair-input-too-large", extra={"chars": len(cleaned)})
|
|
1525
|
+
else:
|
|
1526
|
+
strict_spans = _find_balanced_json_objects(cleaned)
|
|
1527
|
+
lenient_spans = _find_candidate_object_spans(cleaned)
|
|
1528
|
+
|
|
1412
1529
|
# First pass: strict-JSON balanced objects.
|
|
1413
|
-
for start, end, substr in
|
|
1530
|
+
for start, end, substr in strict_spans:
|
|
1414
1531
|
if _in_spans(start, protected):
|
|
1415
1532
|
continue
|
|
1416
1533
|
if _preceded_by_prose_cue(cleaned, start):
|
|
@@ -1436,7 +1553,7 @@ def repair_tool_calls_in_text(
|
|
|
1436
1553
|
|
|
1437
1554
|
# Second pass: lenient objects (single-quoted / unquoted / trailing comma /
|
|
1438
1555
|
# double-brace) that strict parsing missed. Skip spans already claimed.
|
|
1439
|
-
for start, end, substr in
|
|
1556
|
+
for start, end, substr in lenient_spans:
|
|
1440
1557
|
if _in_spans(start, spans_to_remove):
|
|
1441
1558
|
continue
|
|
1442
1559
|
if _in_spans(start, protected):
|
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 (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
542
|
-
"""
|
|
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
|
|
550
|
-
``
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
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
|