@junghanacs/entwurf 0.20.0 → 0.21.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 -5
- package/CHANGELOG.md +220 -14
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +202 -60
- package/README.md +54 -26
- package/VERIFY.md +37 -7
- package/docs/acp-backend-rail.md +31 -15
- package/docs/external-mcp-host.md +58 -35
- package/docs/fresh-cut-policy.md +3 -2
- package/docs/setup-clean-host.md +110 -17
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +101 -109
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +6 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +326 -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-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 +50 -15
- 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/meta-bridge-fresh-cut.js +6 -1
- package/mcp/entwurf-bridge/src/index.ts +115 -111
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +14 -5
- package/pi-extensions/entwurf-control.ts +71 -19
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +3 -3
- package/pi-extensions/lib/acp/event-mapper.ts +4 -4
- package/pi-extensions/lib/acp/tool-surface.ts +6 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +363 -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-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 +64 -19
- 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 +251 -35
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-agy-sender-identity.ts +1 -1
- 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 +203 -11
- package/scripts/check-entwurf-v2-runner.ts +1 -1
- package/scripts/check-entwurf-v2-surface.ts +1 -1
- package/scripts/check-gate-qualification.ts +7 -4
- package/scripts/check-harness-admission-parity.ts +0 -1
- package/scripts/check-install-surface.ts +23 -7
- package/scripts/check-mux-launch-tmux.ts +47 -2
- 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-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-statusline-config.py +434 -0
- package/scripts/codex_toml_io.py +532 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +113 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +399 -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-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 +19 -8
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pi-package-ownership.json +26 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/mutants/v2-surface.json +53 -1
- package/scripts/pi_settings_io.py +3 -1
- package/scripts/raw-async-delivery/README.md +2 -1
- package/scripts/raw-codex-measure/README.md +114 -46
- package/scripts/register-pi-package.py +38 -30
- package/scripts/register-pi-provider.py +3 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- 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 +511 -0
- package/scripts/smoke-codex-fresh-live.ts +1186 -0
- package/scripts/smoke-codex-native-push-live.ts +75 -0
- package/scripts/smoke-setup-verdict.sh +123 -10
- package/scripts/smoke-user-scope-citizen.sh +199 -71
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,532 @@
|
|
|
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 toml_string(value: str) -> str:
|
|
526
|
+
# JSON double-quoted strings are valid TOML basic strings for every value
|
|
527
|
+
# json can emit (\", \\, \n, \uXXXX — all legal TOML escapes).
|
|
528
|
+
return json.dumps(value, ensure_ascii=True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def render_string_array(items) -> str:
|
|
532
|
+
return "[" + ", ".join(toml_string(item) for item in items) + "]"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure message builders and final mailbox selection for Codex first-admission LIVE.
|
|
3
|
+
*
|
|
4
|
+
* Historical model launch/address reports are deliberately gone. The fixture now observes
|
|
5
|
+
* Pi toolCall↔toolResult rows, Codex thread/read MCP items, and raw callback envelopes directly.
|
|
6
|
+
* The one remaining mailbox message is Codex's final source-validated payload; token and sender
|
|
7
|
+
* are selected together, and duplicate exact candidates fail closed.
|
|
8
|
+
*
|
|
9
|
+
* The initial Pi receives its own outer sender_info after the user payload. The forwarded Codex
|
|
10
|
+
* instruction therefore rides between explicit BEGIN/END lines and the prompt says that neither
|
|
11
|
+
* marker nor the generated sender_info after END belongs to the forwarded bytes. Exact source-call
|
|
12
|
+
* arguments enforce that boundary; no suffix stripping repairs model drift after the fact.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Sender envelope line of a delivered meta-mailbox body: ` session: <garden id>`. */
|
|
16
|
+
const SENDER_LINE = /^\s*session:\s+(\S+)/m;
|
|
17
|
+
|
|
18
|
+
export function reportSender(body: string): string | null {
|
|
19
|
+
return SENDER_LINE.exec(body)?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The report whose token AND sender both match. Both halves are decided BEFORE the wait ends:
|
|
24
|
+
* a matching token from somebody else is skipped, not returned and then asserted on.
|
|
25
|
+
*/
|
|
26
|
+
export function selectReport(bodies: readonly string[], token: string, expectedSender: string): string | null {
|
|
27
|
+
const matching: string[] = [];
|
|
28
|
+
for (const body of bodies) {
|
|
29
|
+
if (!body.includes(token)) continue;
|
|
30
|
+
if (reportSender(body) !== expectedSender) continue;
|
|
31
|
+
matching.push(body);
|
|
32
|
+
}
|
|
33
|
+
if (matching.length > 1) throw new Error(`duplicate exact mailbox reports (${matching.length})`);
|
|
34
|
+
return matching[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CodexInstructionInput {
|
|
38
|
+
waitToken: string;
|
|
39
|
+
finalToken: string;
|
|
40
|
+
callerGid: string;
|
|
41
|
+
piModel: string;
|
|
42
|
+
scratch: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The payload the initial Pi forwards verbatim. It names only Codex's own steps. */
|
|
46
|
+
export function buildCodexInstruction(input: CodexInstructionInput): string {
|
|
47
|
+
return (
|
|
48
|
+
`${input.waitToken}\nThis is the addressed instruction from the initial Pi caller. Execute once, with no shell, ` +
|
|
49
|
+
"terminal, screen inspection, or retry:\n" +
|
|
50
|
+
`1. Call public mcp__entwurf_bridge__entwurf_fresh_call once with backend pi, model ${input.piModel}, cwd ${input.scratch}, ` +
|
|
51
|
+
'NO placement, and task "After your automatic callback succeeds, answer ACK and stop. Do not open another sibling."\n' +
|
|
52
|
+
"2. Stop and wait for the Pi callback delivered into this Codex thread. Accept only a callback whose body " +
|
|
53
|
+
"repeats the exact nonce from your LAUNCH receipt and whose sender envelope names a garden id.\n" +
|
|
54
|
+
`3. Only after that match, call public mcp__entwurf_bridge__entwurf_v2 once with target ${input.callerGid}, ` +
|
|
55
|
+
"intent fire-and-forget, wants_reply false, and message:\n" +
|
|
56
|
+
`${input.finalToken}\nPI_LAUNCH_NONCE=<exact launch nonce>\nPI_CALLBACK_NONCE=<same exact nonce>\n` +
|
|
57
|
+
"PI_CALLBACK_FROM=<sender-envelope garden id>\nPI_SESSION_ID=<exact $session id from the LAUNCH receipt>\n" +
|
|
58
|
+
"PI_WINDOW_ID=<exact @window id from the LAUNCH receipt>\nDo not claim completion in prose."
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface InitialPiPhaseOneInput {
|
|
63
|
+
initialPiWaitToken: string;
|
|
64
|
+
codexWaitToken: string;
|
|
65
|
+
codexModel: string;
|
|
66
|
+
scratch: string;
|
|
67
|
+
codexInstruction: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One bounded forwarded payload; source arguments, not model prose, are its oracle. */
|
|
71
|
+
export function buildInitialPiPhaseOne(input: InitialPiPhaseOneInput): string {
|
|
72
|
+
return (
|
|
73
|
+
`${input.initialPiWaitToken}\nExecute this public-tool chain exactly once; do not use shell, terminal, screen text, or retries:\n` +
|
|
74
|
+
`1. Call entwurf_fresh_call with backend codex, model ${input.codexModel}, cwd ${input.scratch}, NO placement, and task ` +
|
|
75
|
+
`"After your required callback receipt, end the turn and wait passively for the addressed instruction containing ${input.codexWaitToken}. ` +
|
|
76
|
+
`Do not call shell, sleep, terminal, or any tool to wait."\n` +
|
|
77
|
+
"2. Stop and wait for the Codex callback. Accept only a callback carrying the exact nonce from the LAUNCH " +
|
|
78
|
+
"receipt; take the Codex garden id only from its sender envelope.\n" +
|
|
79
|
+
"3. Call entwurf_v2 once to that callback-derived Codex garden id with intent fire-and-forget, wants_reply false, " +
|
|
80
|
+
"and message set to the exact bytes strictly between the BEGIN/END marker lines below; the marker lines and " +
|
|
81
|
+
"the generated sender_info after END are not part of the message. Then STOP. Send nothing else and contact " +
|
|
82
|
+
"nobody else.\n" +
|
|
83
|
+
"----- BEGIN MESSAGE FOR THE CODEX SIBLING -----\n" +
|
|
84
|
+
input.codexInstruction +
|
|
85
|
+
"\n----- END MESSAGE FOR THE CODEX SIBLING -----"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CleanupStage {
|
|
90
|
+
label: string;
|
|
91
|
+
run: () => unknown | Promise<unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run every teardown stage even when an earlier recovery or cleanup stage throws. A LIVE must
|
|
96
|
+
* report cleanup trouble, but that trouble must never prevent the later owned resources from
|
|
97
|
+
* being closed. Returned stage failures are labelled so the caller can fold them into its one
|
|
98
|
+
* final verdict without losing the original run failure.
|
|
99
|
+
*/
|
|
100
|
+
export async function runCleanupStages(stages: readonly CleanupStage[]): Promise<string[]> {
|
|
101
|
+
const failures: string[] = [];
|
|
102
|
+
for (const stage of stages) {
|
|
103
|
+
try {
|
|
104
|
+
const reported = await stage.run();
|
|
105
|
+
if (Array.isArray(reported)) {
|
|
106
|
+
for (const failure of reported) failures.push(`${stage.label}: ${failure}`);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failures.push(`${stage.label}: threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return failures;
|
|
113
|
+
}
|