@junghanacs/entwurf 0.20.1 → 0.22.0
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.
- package/AGENTS.md +85 -190
- package/BASELINE.md +6 -3
- package/CHANGELOG.md +473 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +332 -60
- package/README.md +100 -22
- package/VERIFY.md +93 -7
- package/docs/acp-backend-rail.md +0 -1
- package/docs/external-mcp-host.md +64 -33
- package/docs/setup-clean-host.md +151 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +130 -114
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +519 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +22 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +30 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +7 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +20 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +227 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +189 -19
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +10 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +403 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-codex.js +325 -0
- package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +154 -116
- package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
- package/package.json +13 -4
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/codex-caller-seat.ts +204 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +580 -0
- package/pi-extensions/lib/compaction-send-guard.ts +80 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +29 -3
- package/pi-extensions/lib/entwurf-peers-render.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +6 -7
- package/pi-extensions/lib/entwurf-v2-native-push.ts +35 -18
- package/pi-extensions/lib/entwurf-v2-production.ts +10 -3
- package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
- package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
- package/pi-extensions/lib/entwurf-v2-surface.ts +7 -4
- package/pi-extensions/lib/meta-mailbox-body.ts +22 -13
- package/pi-extensions/lib/meta-sender-identity.ts +305 -0
- package/pi-extensions/lib/mux-fresh-call.ts +233 -29
- package/pi-extensions/lib/native-push/adapter.ts +21 -24
- package/pi-extensions/lib/native-push/codex-ws-client.ts +506 -0
- package/pi-extensions/lib/native-push/register.ts +7 -9
- package/pi-extensions/meta-bridge-hook-codex.ts +371 -0
- package/run.sh +342 -28
- package/scripts/check-agy-sender-identity.ts +1 -1
- package/scripts/check-codex-app-server-launch.ts +445 -0
- package/scripts/check-codex-birth-hook.ts +264 -0
- package/scripts/check-codex-bridge-identity.ts +179 -0
- package/scripts/check-codex-native-push.ts +386 -0
- package/scripts/check-codex-sender-identity.ts +495 -0
- package/scripts/check-compaction-send-guard.ts +130 -0
- package/scripts/check-copilot-receive-arm.ts +4 -1
- package/scripts/check-entwurf-fact-provider.ts +38 -0
- package/scripts/check-entwurf-peers-surface.ts +13 -1
- package/scripts/check-entwurf-self-address.ts +15 -16
- package/scripts/check-entwurf-v2-contract.ts +4 -3
- package/scripts/check-entwurf-v2-decider.ts +7 -5
- package/scripts/check-entwurf-v2-native-push.ts +35 -7
- package/scripts/check-entwurf-v2-production.ts +245 -12
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-send.ts +26 -7
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +8 -3
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-mux-launch-tmux.ts +345 -4
- package/scripts/check-native-push-adapter.ts +20 -16
- package/scripts/check-native-push-register.ts +5 -1
- package/scripts/check-release-gate-outcomes.ts +47 -1
- package/scripts/check-setup-qualification.sh +3 -1
- package/scripts/codex-app-server-launch.sh +275 -0
- package/scripts/codex-birth-doctor.sh +276 -0
- package/scripts/codex-birth-install.sh +414 -0
- package/scripts/codex-birth-uninstall.sh +170 -0
- package/scripts/codex-mcp-config.py +435 -0
- package/scripts/codex-socket-path.ts +33 -0
- package/scripts/codex-statusline-config.py +434 -0
- package/scripts/codex-terminal-title-config.py +500 -0
- package/scripts/codex_toml_io.py +653 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +121 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +426 -0
- package/scripts/lib/launch-receipt-windows.ts +46 -0
- package/scripts/lib/tmux-coordinate-row.ts +58 -0
- package/scripts/meta-bridge-fresh-cut.ts +6 -1
- package/scripts/mutants/codex-app-server-launch.json +157 -0
- package/scripts/mutants/codex-caller-seat.json +336 -0
- package/scripts/mutants/codex-native.json +838 -0
- package/scripts/mutants/compaction-send-guard.json +103 -0
- package/scripts/mutants/entwurf-peers.json +19 -0
- package/scripts/mutants/mux-fresh-call.json +93 -10
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +75 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/smoke-agy-native-push-live.ts +3 -1
- package/scripts/smoke-codex-birth.sh +347 -0
- package/scripts/smoke-codex-config-state.sh +700 -0
- package/scripts/smoke-codex-fresh-live.ts +1426 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-entwurf-chain-live.ts +50 -0
- package/scripts/smoke-setup-verdict.sh +125 -10
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""codex_toml_io — shared TOML reader/writer for the two Codex config atoms.
|
|
2
|
+
|
|
3
|
+
`$CODEX_HOME/config.toml` is a SINGLE shared surface: the operator hand-edits
|
|
4
|
+
it AND the vendor writes it back at runtime (answering the hook-trust prompt
|
|
5
|
+
appends `[hooks.state."..."]` to the very same file — measured in
|
|
6
|
+
scripts/raw-codex-measure/README.md M1). The Copilot atoms could own a whole
|
|
7
|
+
JSON document; a Codex atom cannot. This module is therefore the
|
|
8
|
+
`scripts/omp-config-xdev.py` shape — a line editor that owns exactly the lines
|
|
9
|
+
it touches — hardened with three layers that file never needed:
|
|
10
|
+
|
|
11
|
+
1. tomllib (stdlib, vendor-grade) is the parse-or-refuse oracle: a file that
|
|
12
|
+
does not parse is refused before any edit, and every CANDIDATE text is
|
|
13
|
+
re-parsed before it may reach the disk. A line edit that would break the
|
|
14
|
+
document can therefore never ship.
|
|
15
|
+
2. Every write proves its own blast radius: with the one path the caller
|
|
16
|
+
owns removed from both sides, the parsed before/after documents must
|
|
17
|
+
compare equal (modulo empty tables, which carry no content) or the write
|
|
18
|
+
is refused as an internal error. The ownership policy is a check, not a
|
|
19
|
+
promise.
|
|
20
|
+
3. `atomic_write` is a single fail-closed primitive (mkdir, write-to-temp,
|
|
21
|
+
rename) that both atoms call for BOTH files they own. Each atom writes
|
|
22
|
+
its install-state receipt — which already carries the first preimage —
|
|
23
|
+
BEFORE it ever touches the operator's config, so a crash or an I/O
|
|
24
|
+
failure at any of the three points leaves the operator's bytes exactly
|
|
25
|
+
as they were and a receipt that is either absent or already correct; a
|
|
26
|
+
retry resumes from it instead of re-deriving a preimage from
|
|
27
|
+
already-mutated bytes. `ENTWURF_TEST_CODEX_TOML_FAULT` injects a
|
|
28
|
+
deterministic failure at each of the three points for exactly this
|
|
29
|
+
property (see `_inject_test_fault`).
|
|
30
|
+
|
|
31
|
+
There is deliberately NO whole-file postimage hash here: the vendor writes
|
|
32
|
+
this file between our install and our uninstall, so byte-pinning would refuse
|
|
33
|
+
honest uninstalls. Spans are located FRESH from the current bytes at uninstall
|
|
34
|
+
time; the atoms record the span texts they produced and the preimages they
|
|
35
|
+
superseded.
|
|
36
|
+
|
|
37
|
+
Importers prepend their own directory to sys.path so the import holds however
|
|
38
|
+
they are invoked (same pattern as pi_settings_io).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import datetime
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
import tempfile
|
|
49
|
+
import tomllib
|
|
50
|
+
|
|
51
|
+
_BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def die(code: int, message: str) -> None:
|
|
55
|
+
sys.stderr.write(message.rstrip("\n") + "\n")
|
|
56
|
+
raise SystemExit(code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def now() -> str:
|
|
60
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_text(path: str, label: str) -> str | None:
|
|
64
|
+
"""The file's bytes, or None when absent. Anything else is refused."""
|
|
65
|
+
try:
|
|
66
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
67
|
+
return handle.read()
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return None
|
|
70
|
+
except OSError as error:
|
|
71
|
+
die(4, f"codex-toml: cannot read {label} {path}: {error}")
|
|
72
|
+
except UnicodeDecodeError as error:
|
|
73
|
+
die(4, f"codex-toml: {label} {path} is not valid UTF-8: {error}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_toml(text: str, label: str) -> dict:
|
|
77
|
+
try:
|
|
78
|
+
value = tomllib.loads(text)
|
|
79
|
+
except tomllib.TOMLDecodeError as error:
|
|
80
|
+
die(4, f"codex-toml: {label} is not valid TOML: {error}")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_TEST_FAULT_ENV = "ENTWURF_TEST_CODEX_TOML_FAULT"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _inject_test_fault(stage: str, prefix: str) -> None:
|
|
88
|
+
"""Deterministic failure injection for the smoke suite only. Set
|
|
89
|
+
`ENTWURF_TEST_CODEX_TOML_FAULT` to `<stage>` or `<stage>:<prefix>`, where
|
|
90
|
+
`stage` is one of `mkdir` / `write` / `replace` — the three points
|
|
91
|
+
`atomic_write` can fail at, in order — to raise there before a single
|
|
92
|
+
byte of the target path changes. `prefix` narrows the fault to one
|
|
93
|
+
writer's temp-file prefix (`.codex-state-` for a receipt,
|
|
94
|
+
`.codex-mcp-`/`.codex-sl-` for a config); omitted, it matches every
|
|
95
|
+
writer. Never read outside a test that sets the variable.
|
|
96
|
+
"""
|
|
97
|
+
spec = os.environ.get(_TEST_FAULT_ENV)
|
|
98
|
+
if not spec:
|
|
99
|
+
return
|
|
100
|
+
want_stage, _, want_prefix = spec.partition(":")
|
|
101
|
+
if want_stage != stage or (want_prefix and want_prefix != prefix):
|
|
102
|
+
return
|
|
103
|
+
raise OSError(f"codex-toml: injected {stage} fault ({prefix})")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def atomic_write(path: str, text: str, prefix: str = ".codex-toml-") -> None:
|
|
107
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
108
|
+
_inject_test_fault("mkdir", prefix)
|
|
109
|
+
os.makedirs(directory, exist_ok=True)
|
|
110
|
+
_inject_test_fault("write", prefix)
|
|
111
|
+
fd, temporary = tempfile.mkstemp(dir=directory, prefix=prefix, suffix=".tmp")
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
114
|
+
handle.write(text)
|
|
115
|
+
_inject_test_fault("replace", prefix)
|
|
116
|
+
os.replace(temporary, path)
|
|
117
|
+
except BaseException:
|
|
118
|
+
try:
|
|
119
|
+
os.unlink(temporary)
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def atomic_write_state(path: str, state: dict) -> None:
|
|
126
|
+
atomic_write(path, json.dumps(state, indent=2) + "\n", prefix=".codex-state-")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
# Char-level scanning: which lines are headers, where a value ends.
|
|
131
|
+
#
|
|
132
|
+
# The scanner only has to be RIGHT about the files tomllib already accepted;
|
|
133
|
+
# every span it reports is re-parsed in isolation and compared against the
|
|
134
|
+
# whole-document value (`span_value` below). A mis-track fails closed there,
|
|
135
|
+
# never silently.
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _ScanState:
|
|
140
|
+
"""Bracket/string context carried across lines."""
|
|
141
|
+
|
|
142
|
+
__slots__ = ("depth", "basic_ml", "literal_ml")
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self.depth = 0 # net open [ or { outside strings (multi-line arrays)
|
|
146
|
+
self.basic_ml = False # inside a """ multi-line basic string
|
|
147
|
+
self.literal_ml = False # inside a ''' multi-line literal string
|
|
148
|
+
|
|
149
|
+
def busy(self) -> bool:
|
|
150
|
+
return self.depth > 0 or self.basic_ml or self.literal_ml
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _scan_text(text: str, state: _ScanState, start: int = 0) -> int:
|
|
154
|
+
"""Advance over `text` from `start`, mutating `state`; return the resume
|
|
155
|
+
index (len(text) when the construct is still open). Newlines are ordinary
|
|
156
|
+
characters to the caller's context: inside multi-line strings and arrays
|
|
157
|
+
they are content; elsewhere the caller checks `state.busy()` at EOL.
|
|
158
|
+
Comments (# outside any string) end the rest of the text.
|
|
159
|
+
"""
|
|
160
|
+
i = start
|
|
161
|
+
n = len(text)
|
|
162
|
+
in_basic = False # inside a single-line basic string
|
|
163
|
+
in_literal = False # inside a single-line literal string
|
|
164
|
+
escaped = False
|
|
165
|
+
while i < n:
|
|
166
|
+
ch = text[i]
|
|
167
|
+
if state.basic_ml:
|
|
168
|
+
if ch == '"':
|
|
169
|
+
run = 1
|
|
170
|
+
while i + run < n and text[i + run] == '"':
|
|
171
|
+
run += 1
|
|
172
|
+
if run >= 3:
|
|
173
|
+
# first three close the string; up to two extra quotes are
|
|
174
|
+
# content per the TOML spec and cannot open anything.
|
|
175
|
+
state.basic_ml = False
|
|
176
|
+
i += 3
|
|
177
|
+
continue
|
|
178
|
+
i += run # 1-2 quotes: content
|
|
179
|
+
continue
|
|
180
|
+
i += 1
|
|
181
|
+
continue
|
|
182
|
+
if state.literal_ml:
|
|
183
|
+
if ch == "'":
|
|
184
|
+
run = 1
|
|
185
|
+
while i + run < n and text[i + run] == "'":
|
|
186
|
+
run += 1
|
|
187
|
+
if run >= 3:
|
|
188
|
+
state.literal_ml = False
|
|
189
|
+
i += 3
|
|
190
|
+
continue
|
|
191
|
+
i += run
|
|
192
|
+
continue
|
|
193
|
+
i += 1
|
|
194
|
+
continue
|
|
195
|
+
if in_basic:
|
|
196
|
+
if escaped:
|
|
197
|
+
escaped = False
|
|
198
|
+
elif ch == "\\":
|
|
199
|
+
escaped = True
|
|
200
|
+
elif ch == '"':
|
|
201
|
+
in_basic = False
|
|
202
|
+
i += 1
|
|
203
|
+
continue
|
|
204
|
+
if in_literal:
|
|
205
|
+
if ch == "'":
|
|
206
|
+
in_literal = False
|
|
207
|
+
i += 1
|
|
208
|
+
continue
|
|
209
|
+
# outside every string
|
|
210
|
+
if ch == '"':
|
|
211
|
+
run = 1
|
|
212
|
+
while i + run < n and text[i + run] == '"':
|
|
213
|
+
run += 1
|
|
214
|
+
if run >= 3:
|
|
215
|
+
state.basic_ml = True
|
|
216
|
+
i += 3
|
|
217
|
+
elif run == 2:
|
|
218
|
+
i += 2 # empty string
|
|
219
|
+
else:
|
|
220
|
+
in_basic = True
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
if ch == "'":
|
|
224
|
+
run = 1
|
|
225
|
+
while i + run < n and text[i + run] == "'":
|
|
226
|
+
run += 1
|
|
227
|
+
if run >= 3:
|
|
228
|
+
state.literal_ml = True
|
|
229
|
+
i += 3
|
|
230
|
+
elif run == 2:
|
|
231
|
+
i += 2 # empty string
|
|
232
|
+
else:
|
|
233
|
+
in_literal = True
|
|
234
|
+
i += 1
|
|
235
|
+
continue
|
|
236
|
+
if ch == "#":
|
|
237
|
+
return n # comment: rest of the text is inert
|
|
238
|
+
if ch in "[{":
|
|
239
|
+
state.depth += 1
|
|
240
|
+
elif ch in "]}":
|
|
241
|
+
if state.depth > 0:
|
|
242
|
+
state.depth -= 1
|
|
243
|
+
i += 1
|
|
244
|
+
return i
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _unquote(segment: str) -> str | None:
|
|
248
|
+
"""One dotted-path segment back to its key string, or None if malformed."""
|
|
249
|
+
if len(segment) >= 2 and segment[0] == '"' and segment[-1] == '"':
|
|
250
|
+
try:
|
|
251
|
+
return json.loads(segment)
|
|
252
|
+
except ValueError:
|
|
253
|
+
return None
|
|
254
|
+
if len(segment) >= 2 and segment[0] == "'" and segment[-1] == "'":
|
|
255
|
+
return segment[1:-1]
|
|
256
|
+
if _BARE_KEY.fullmatch(segment):
|
|
257
|
+
return segment
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parse_header(stripped: str) -> tuple[tuple[str, ...], bool] | None:
|
|
262
|
+
"""The dotted path of a `[table]` / `[[array]]` header line, or None when
|
|
263
|
+
the line is not a parseable header (tomllib is the backstop oracle)."""
|
|
264
|
+
is_array = stripped.startswith("[[")
|
|
265
|
+
opener, closer = ("[[", "]]") if is_array else ("[", "]")
|
|
266
|
+
if not stripped.startswith(opener):
|
|
267
|
+
return None
|
|
268
|
+
end = stripped.find(closer)
|
|
269
|
+
if end < 0:
|
|
270
|
+
return None
|
|
271
|
+
body = stripped[len(opener):end]
|
|
272
|
+
parts: list[str] = []
|
|
273
|
+
current = []
|
|
274
|
+
in_basic = in_literal = escaped = False
|
|
275
|
+
for ch in body:
|
|
276
|
+
if in_basic:
|
|
277
|
+
if escaped:
|
|
278
|
+
escaped = False
|
|
279
|
+
elif ch == "\\":
|
|
280
|
+
escaped = True
|
|
281
|
+
elif ch == '"':
|
|
282
|
+
in_basic = False
|
|
283
|
+
current.append(ch)
|
|
284
|
+
continue
|
|
285
|
+
if in_literal:
|
|
286
|
+
if ch == "'":
|
|
287
|
+
in_literal = False
|
|
288
|
+
current.append(ch)
|
|
289
|
+
continue
|
|
290
|
+
if ch == '"':
|
|
291
|
+
in_basic = True
|
|
292
|
+
current.append(ch)
|
|
293
|
+
elif ch == "'":
|
|
294
|
+
in_literal = True
|
|
295
|
+
current.append(ch)
|
|
296
|
+
elif ch == ".":
|
|
297
|
+
parts.append("".join(current).strip())
|
|
298
|
+
current = []
|
|
299
|
+
else:
|
|
300
|
+
current.append(ch)
|
|
301
|
+
parts.append("".join(current).strip())
|
|
302
|
+
if any(not part for part in parts):
|
|
303
|
+
return None
|
|
304
|
+
keys = tuple(_unquote(part) for part in parts)
|
|
305
|
+
if any(key is None for key in keys):
|
|
306
|
+
return None
|
|
307
|
+
return keys, is_array # type: ignore[return-value]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class LineInfo:
|
|
311
|
+
__slots__ = ("header", "is_array")
|
|
312
|
+
|
|
313
|
+
def __init__(self, header: tuple[str, ...] | None, is_array: bool) -> None:
|
|
314
|
+
self.header = header
|
|
315
|
+
self.is_array = is_array
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def scan_lines(lines: list[str]) -> list[LineInfo]:
|
|
319
|
+
"""Classify every line: its table path when it is a header line at value
|
|
320
|
+
depth zero, else None. Multi-line strings and arrays suppress headers."""
|
|
321
|
+
infos: list[LineInfo] = []
|
|
322
|
+
state = _ScanState()
|
|
323
|
+
for line in lines:
|
|
324
|
+
header: tuple[str, ...] | None = None
|
|
325
|
+
is_array = False
|
|
326
|
+
if not state.busy():
|
|
327
|
+
stripped = line.lstrip()
|
|
328
|
+
if stripped.startswith("["):
|
|
329
|
+
parsed = _parse_header(stripped)
|
|
330
|
+
if parsed is not None:
|
|
331
|
+
header, is_array = parsed
|
|
332
|
+
infos.append(LineInfo(header, is_array))
|
|
333
|
+
_scan_text(line, state)
|
|
334
|
+
return infos
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def find_table_family(lines: list[str], infos: list[LineInfo], path: tuple[str, ...]) -> tuple[int, int] | None:
|
|
338
|
+
"""(start, end_exclusive) of the `[path]` header line and every sub-table
|
|
339
|
+
under it, up to the next header that is not a descendant, or EOF.
|
|
340
|
+
|
|
341
|
+
The BLANK LINES that sit between this family and whatever follows it are NOT
|
|
342
|
+
part of it. They are the separator two neighbours share, and the neighbour is
|
|
343
|
+
frequently not ours: measured on this host, the Codex vendor appended its own
|
|
344
|
+
`[hooks.state]` after our managed MCP block, and re-splicing the family over a
|
|
345
|
+
span that swallowed the separator deleted one of the vendor's bytes on a plain
|
|
346
|
+
idempotent reinstall — semantically nothing, but a byte outside our atom that
|
|
347
|
+
we had promised never to touch.
|
|
348
|
+
|
|
349
|
+
Only whitespace-only lines are given back. A COMMENT above the next header is
|
|
350
|
+
left inside the family exactly as before: a comment is somebody's words, and
|
|
351
|
+
deciding whose they are is a different question from where a table ends. This
|
|
352
|
+
boundary change must not quietly hand ownership of one to the other."""
|
|
353
|
+
start = None
|
|
354
|
+
for idx, info in enumerate(infos):
|
|
355
|
+
if info.header == path and not info.is_array:
|
|
356
|
+
start = idx
|
|
357
|
+
break
|
|
358
|
+
if start is None:
|
|
359
|
+
return None
|
|
360
|
+
end = len(lines)
|
|
361
|
+
for idx in range(start + 1, len(lines)):
|
|
362
|
+
info = infos[idx]
|
|
363
|
+
if info.header is None:
|
|
364
|
+
continue
|
|
365
|
+
other = info.header
|
|
366
|
+
if len(other) > len(path) and other[: len(path)] == path:
|
|
367
|
+
continue # a descendant sub-table stays in the family
|
|
368
|
+
end = idx
|
|
369
|
+
break
|
|
370
|
+
while end > start + 1 and lines[end - 1].strip() == "":
|
|
371
|
+
end -= 1
|
|
372
|
+
return start, end
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _parse_key_prefix(line: str) -> tuple[str, int] | None:
|
|
376
|
+
"""(key, index_of_equals) when `line` begins a `key = value` entry, else
|
|
377
|
+
None. Handles bare and quoted keys; dotted keys are returned verbatim so
|
|
378
|
+
callers can refuse them explicitly."""
|
|
379
|
+
stripped = line.lstrip()
|
|
380
|
+
if not stripped:
|
|
381
|
+
return None
|
|
382
|
+
if stripped[0] in "\"'":
|
|
383
|
+
quote = stripped[0]
|
|
384
|
+
close = stripped.find(quote, 1)
|
|
385
|
+
if close < 0:
|
|
386
|
+
return None
|
|
387
|
+
key = _unquote(stripped[: close + 1])
|
|
388
|
+
if key is None:
|
|
389
|
+
return None
|
|
390
|
+
rest = stripped[close + 1 :]
|
|
391
|
+
else:
|
|
392
|
+
match = _BARE_KEY.match(stripped)
|
|
393
|
+
if not match:
|
|
394
|
+
return None
|
|
395
|
+
key = match.group(0)
|
|
396
|
+
rest = stripped[match.end():]
|
|
397
|
+
if rest.startswith("."):
|
|
398
|
+
return key + "." + "…dotted", -1 # caller refuses; never a plain key
|
|
399
|
+
rest = rest.lstrip()
|
|
400
|
+
if not rest.startswith("="):
|
|
401
|
+
return None
|
|
402
|
+
return key, line.index("=", line.index(stripped))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def find_direct_key(lines: list[str], infos: list[LineInfo], family_start: int, family_end: int, key: str) -> tuple[int, int] | None:
|
|
406
|
+
"""(key_line, value_end_inclusive) of the direct `key = value` entry of
|
|
407
|
+
the table whose header is family_start, or None. The value span covers
|
|
408
|
+
multi-line arrays/strings."""
|
|
409
|
+
for idx in range(family_start + 1, family_end):
|
|
410
|
+
if infos[idx].header is not None:
|
|
411
|
+
break # direct members end at the first sub-table header
|
|
412
|
+
parsed = _parse_key_prefix(lines[idx])
|
|
413
|
+
if parsed is None:
|
|
414
|
+
continue
|
|
415
|
+
found_key, eq = parsed
|
|
416
|
+
if found_key != key:
|
|
417
|
+
continue
|
|
418
|
+
value_end = _value_end(lines, idx, eq + 1)
|
|
419
|
+
return idx, value_end
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _value_end(lines: list[str], key_line: int, start_pos: int) -> int:
|
|
424
|
+
"""The last line index of the value that starts at start_pos on
|
|
425
|
+
lines[key_line]. Single-line values return key_line."""
|
|
426
|
+
state = _ScanState()
|
|
427
|
+
line = lines[key_line]
|
|
428
|
+
resume = _scan_text(line, state, start_pos)
|
|
429
|
+
if not state.busy():
|
|
430
|
+
return key_line
|
|
431
|
+
idx = key_line
|
|
432
|
+
while state.busy() and idx + 1 < len(lines):
|
|
433
|
+
idx += 1
|
|
434
|
+
_scan_text(lines[idx], state)
|
|
435
|
+
return idx
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def span_text(lines: list[str], start: int, end_exclusive: int) -> str:
|
|
439
|
+
return "\n".join(lines[start:end_exclusive])
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def splice(lines: list[str], start: int, end_exclusive: int, replacement: list[str]) -> list[str]:
|
|
443
|
+
return lines[:start] + list(replacement) + lines[end_exclusive:]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def append_block(lines: list[str], block_lines: list[str]) -> list[str]:
|
|
447
|
+
"""Append a table block at EOF, terminating an unterminated last line."""
|
|
448
|
+
body = list(lines)
|
|
449
|
+
if body and body[-1] == "":
|
|
450
|
+
body = body[:-1]
|
|
451
|
+
return body + list(block_lines)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def render_file(lines: list[str]) -> str:
|
|
455
|
+
"""Text for the whole file: exactly one trailing newline, never two — a
|
|
456
|
+
split tail element of "" already IS the terminator."""
|
|
457
|
+
if lines and lines[-1] == "":
|
|
458
|
+
return "\n".join(lines)
|
|
459
|
+
return "\n".join(lines) + "\n"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def span_value(span: str, label: str):
|
|
463
|
+
"""Re-parse an isolated value/key span and return what TOML says it is —
|
|
464
|
+
the self-check that keeps the line scanner honest. A family table span
|
|
465
|
+
parses to {"mcp_servers": {"entwurf-bridge": {...}}}; pass the inner path
|
|
466
|
+
to dig out the compared value."""
|
|
467
|
+
try:
|
|
468
|
+
return tomllib.loads(span)
|
|
469
|
+
except tomllib.TOMLDecodeError as error:
|
|
470
|
+
die(7, f"codex-toml: internal scanner drift: {label} span does not re-parse: {error}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def deep_get(data, path: tuple[str, ...]):
|
|
474
|
+
current = data
|
|
475
|
+
for part in path:
|
|
476
|
+
if not isinstance(current, dict) or part not in current:
|
|
477
|
+
return None
|
|
478
|
+
current = current[part]
|
|
479
|
+
return current
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _drop_empty(value):
|
|
483
|
+
if isinstance(value, dict):
|
|
484
|
+
kept = {k: _drop_empty(v) for k, v in value.items()}
|
|
485
|
+
return {k: v for k, v in kept.items() if v != {}}
|
|
486
|
+
return value
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def without_path(data: dict, path: tuple[str, ...]) -> dict:
|
|
490
|
+
"""A comparable copy of `data` with `path` removed; empty tables left by
|
|
491
|
+
the removal (or empty anywhere — they carry no content) are dropped so an
|
|
492
|
+
appended parent table never reads as collateral damage."""
|
|
493
|
+
if not path:
|
|
494
|
+
return _drop_empty(data)
|
|
495
|
+
|
|
496
|
+
def strip(value, remaining):
|
|
497
|
+
if not isinstance(value, dict):
|
|
498
|
+
return value
|
|
499
|
+
out = {}
|
|
500
|
+
head, rest = remaining[0], remaining[1:]
|
|
501
|
+
for key, child in value.items():
|
|
502
|
+
if key == head:
|
|
503
|
+
if not rest:
|
|
504
|
+
continue
|
|
505
|
+
stripped = strip(child, rest)
|
|
506
|
+
if stripped != {}:
|
|
507
|
+
out[key] = stripped
|
|
508
|
+
continue
|
|
509
|
+
out[key] = child
|
|
510
|
+
return out
|
|
511
|
+
|
|
512
|
+
return _drop_empty(strip(data, path))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def assert_blast_radius(original_text: str | None, candidate_text: str, owned_path: tuple[str, ...], label: str) -> tuple[dict, dict]:
|
|
516
|
+
"""The write's own proof: candidate parses, and with `owned_path` removed
|
|
517
|
+
both documents compare equal. Returns (parsed_before, parsed_after)."""
|
|
518
|
+
before = parse_toml(original_text if original_text is not None else "", label)
|
|
519
|
+
after = parse_toml(candidate_text, f"candidate {label}")
|
|
520
|
+
if without_path(before, owned_path) != without_path(after, owned_path):
|
|
521
|
+
die(7, f"codex-toml: internal refusal: editing {label} would change TOML outside {'.'.join(owned_path)}")
|
|
522
|
+
return before, after
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def array_append_point(span: str, search_from: int = 0) -> tuple[int, str] | None:
|
|
526
|
+
"""Where a new item goes at the END of the first array in `span`, as
|
|
527
|
+
`(insert_index, tail)` — or None when this span holds no array this writer
|
|
528
|
+
can own.
|
|
529
|
+
|
|
530
|
+
`tail` says what the last meaningful byte inside the array was, because
|
|
531
|
+
that decides the snippet an atom appends:
|
|
532
|
+
|
|
533
|
+
``empty`` the array has no items — insert ``"x"``
|
|
534
|
+
``item`` it ends with an item — insert ``, "x"``
|
|
535
|
+
``comma`` it ends with a trailing comma (the multi-line style)
|
|
536
|
+
— insert `` "x",`` AFTER that comma
|
|
537
|
+
|
|
538
|
+
The naive locator — append before ``span.rfind("]")`` — is wrong on three
|
|
539
|
+
real operator shapes: a trailing comment (``items = ["a"] # keep ] this``),
|
|
540
|
+
a ``]`` inside a string item, and the multi-line array whose last item
|
|
541
|
+
already carries a comma (appending ``, "x"`` there emits ``,,``). So this
|
|
542
|
+
walks the span with the SAME string/comment rules `_scan_text` applies, but
|
|
543
|
+
reports POSITIONS, which `_scan_text` cannot: it is line-oriented (its
|
|
544
|
+
single-line string and escape flags are per call, so it may not be driven
|
|
545
|
+
character by character) and it returns only a resume index. Hence one
|
|
546
|
+
self-contained walk here rather than a wrapper.
|
|
547
|
+
|
|
548
|
+
Returning None is a SHAPE answer, never an error: a span with no
|
|
549
|
+
line-shaped array is a thing this writer does not own, and the caller
|
|
550
|
+
refuses it by name instead of splicing into someone's comment.
|
|
551
|
+
"""
|
|
552
|
+
i = search_from
|
|
553
|
+
n = len(span)
|
|
554
|
+
depth = 0
|
|
555
|
+
open_at = -1
|
|
556
|
+
last_meaningful = -1 # index AFTER the last non-space byte inside the array
|
|
557
|
+
basic_ml = literal_ml = in_basic = in_literal = escaped = False
|
|
558
|
+
|
|
559
|
+
def mark(end: int) -> None:
|
|
560
|
+
nonlocal last_meaningful
|
|
561
|
+
if depth >= 1:
|
|
562
|
+
last_meaningful = end
|
|
563
|
+
|
|
564
|
+
while i < n:
|
|
565
|
+
ch = span[i]
|
|
566
|
+
if basic_ml or literal_ml:
|
|
567
|
+
quote = '"' if basic_ml else "'"
|
|
568
|
+
if ch == quote:
|
|
569
|
+
run = 1
|
|
570
|
+
while i + run < n and span[i + run] == quote:
|
|
571
|
+
run += 1
|
|
572
|
+
if run >= 3:
|
|
573
|
+
basic_ml = literal_ml = False
|
|
574
|
+
i += 3
|
|
575
|
+
mark(i)
|
|
576
|
+
continue
|
|
577
|
+
i += run
|
|
578
|
+
mark(i)
|
|
579
|
+
continue
|
|
580
|
+
i += 1
|
|
581
|
+
mark(i)
|
|
582
|
+
continue
|
|
583
|
+
if in_basic:
|
|
584
|
+
if escaped:
|
|
585
|
+
escaped = False
|
|
586
|
+
elif ch == "\\":
|
|
587
|
+
escaped = True
|
|
588
|
+
elif ch == '"':
|
|
589
|
+
in_basic = False
|
|
590
|
+
i += 1
|
|
591
|
+
mark(i)
|
|
592
|
+
continue
|
|
593
|
+
if in_literal:
|
|
594
|
+
if ch == "'":
|
|
595
|
+
in_literal = False
|
|
596
|
+
i += 1
|
|
597
|
+
mark(i)
|
|
598
|
+
continue
|
|
599
|
+
if ch in "\"'":
|
|
600
|
+
run = 1
|
|
601
|
+
while i + run < n and span[i + run] == ch:
|
|
602
|
+
run += 1
|
|
603
|
+
if run >= 3:
|
|
604
|
+
basic_ml, literal_ml = ch == '"', ch == "'"
|
|
605
|
+
i += 3
|
|
606
|
+
elif run == 2:
|
|
607
|
+
i += 2 # an empty string opens nothing
|
|
608
|
+
else:
|
|
609
|
+
in_basic, in_literal = ch == '"', ch == "'"
|
|
610
|
+
i += 1
|
|
611
|
+
mark(i)
|
|
612
|
+
continue
|
|
613
|
+
if ch == "#":
|
|
614
|
+
# A comment runs to end of line and is NOT meaningful content.
|
|
615
|
+
# Inside an OPEN array that is legal TOML and the array continues
|
|
616
|
+
# on the next line; outside one there is nothing left to close.
|
|
617
|
+
newline = span.find("\n", i)
|
|
618
|
+
if depth == 0 or newline < 0:
|
|
619
|
+
return None
|
|
620
|
+
i = newline + 1
|
|
621
|
+
continue
|
|
622
|
+
if ch in "[{":
|
|
623
|
+
depth += 1
|
|
624
|
+
if depth == 1 and ch == "[":
|
|
625
|
+
open_at = i
|
|
626
|
+
mark(i + 1)
|
|
627
|
+
i += 1
|
|
628
|
+
continue
|
|
629
|
+
if ch in "]}":
|
|
630
|
+
depth -= 1
|
|
631
|
+
if depth < 0:
|
|
632
|
+
return None
|
|
633
|
+
if depth == 0 and ch == "]" and open_at >= 0:
|
|
634
|
+
if last_meaningful <= open_at + 1:
|
|
635
|
+
return open_at + 1, "empty"
|
|
636
|
+
return last_meaningful, "comma" if span[last_meaningful - 1] == "," else "item"
|
|
637
|
+
mark(i + 1)
|
|
638
|
+
i += 1
|
|
639
|
+
continue
|
|
640
|
+
i += 1
|
|
641
|
+
if not ch.isspace():
|
|
642
|
+
mark(i)
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def toml_string(value: str) -> str:
|
|
647
|
+
# JSON double-quoted strings are valid TOML basic strings for every value
|
|
648
|
+
# json can emit (\", \\, \n, \uXXXX — all legal TOML escapes).
|
|
649
|
+
return json.dumps(value, ensure_ascii=True)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def render_string_array(items) -> str:
|
|
653
|
+
return "[" + ", ".join(toml_string(item) for item in items) + "]"
|