@junghanacs/entwurf 0.22.0 → 0.23.1
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 +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- 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 +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- 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 +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-declaration — what entwurf owns inside `$CODEX_HOME/hooks.json`, and nothing more.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS LEAF EXISTS (#117). The birth unit used to own hooks.json as ONE COMPLETE FILE:
|
|
5
|
+
* install refused a file it had not written, the state recorded a WHOLE-FILE sha256, and the
|
|
6
|
+
* doctor/preflight compared that digest to the live bytes. That was true only while entwurf was
|
|
7
|
+
* the only thing declaring a Codex hook. It is not: Herdr's official Codex integration appends
|
|
8
|
+
* its own `SessionStart` group through `ensure_command_hook`, and the vendor keeps running BOTH
|
|
9
|
+
* — `[source]` codex-rs/hooks/src/engine/discovery.rs:664-665 hashes a NORMALIZED
|
|
10
|
+
* event/matcher/group/handler and keys trust by `<path>:<event>:<group_idx>:<handler_idx>`, so
|
|
11
|
+
* trust is declaration-scoped and always was. Only entwurf's certification was file-scoped.
|
|
12
|
+
*
|
|
13
|
+
* WHAT OWNERSHIP MEANS HERE INSTEAD: exactly one `SessionStart` matcher group, holding exactly
|
|
14
|
+
* one handler whose `command` is our quoted absolute launcher path. Everything else in that file
|
|
15
|
+
* is FOREIGN — reported, never certified, never rewritten, never absorbed.
|
|
16
|
+
*
|
|
17
|
+
* WHY A NORMALIZED DIGEST AND NOT BYTES. `[측정 2026-09-17 oracle]` after Herdr appended its
|
|
18
|
+
* group, our own handler came back with its keys in `command,timeout,type` order while the
|
|
19
|
+
* installer writes `type,command,timeout` — Herdr re-serialized the whole document through
|
|
20
|
+
* serde. So neither the file's bytes NOR our group's raw bytes survive a neighbour's install.
|
|
21
|
+
* The digest below is taken over a canonical form (recursively sorted keys, no whitespace), so
|
|
22
|
+
* it is blind to key order, indentation, `description` and every neighbouring group, and
|
|
23
|
+
* sensitive to exactly what the trust identity is made of: the launcher path and the timeout.
|
|
24
|
+
*
|
|
25
|
+
* WHY A SPAN SPLICE AND NOT A RE-SERIALIZE. Adding or removing our group by parsing the file and
|
|
26
|
+
* writing `JSON.stringify` back would rewrite every foreign byte on the way past. This module
|
|
27
|
+
* locates our group's exact text span and edits only that, so a neighbour's declaration comes
|
|
28
|
+
* out of an install or an uninstall byte-for-byte identical. Every splice is verified by
|
|
29
|
+
* re-parsing the result and deep-comparing it to the value the caller expected; a splice that
|
|
30
|
+
* does not land exactly there is a REFUSAL, never a written file.
|
|
31
|
+
*
|
|
32
|
+
* This file is `.js` for the same reason `session-id.js` is: it is imported from the tsc-emit
|
|
33
|
+
* path (`codex-fresh-preflight.ts`), from `node --experimental-strip-types` gates, and from the
|
|
34
|
+
* three installer shells through `node -e 'import(...)'`. One definition, every consumer.
|
|
35
|
+
*
|
|
36
|
+
* Keep dependency-free except `node:crypto`.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash } from "node:crypto";
|
|
39
|
+
/** The vendor event name as it appears in hooks.json. */
|
|
40
|
+
export const CODEX_BIRTH_EVENT = "SessionStart";
|
|
41
|
+
/** The same event as it appears inside a `[hooks.state]` trust key — the vendor lower-snakes it. */
|
|
42
|
+
export const CODEX_BIRTH_TRUST_EVENT = "session_start";
|
|
43
|
+
/** Part of the identity the operator approves once; not a tunable. */
|
|
44
|
+
export const CODEX_BIRTH_TIMEOUT = 30;
|
|
45
|
+
/** A `description` this value prefixes is entwurf prose, and the only top-level member we own. */
|
|
46
|
+
export const CODEX_BIRTH_DESCRIPTION_PREFIX = "entwurf codex-birth ";
|
|
47
|
+
/**
|
|
48
|
+
* The command string the vendor sees. Codex runs a handler command as a SHELL STRING (there is
|
|
49
|
+
* no argv form), so the absolute launcher path is single-quoted — and THAT string, quotes
|
|
50
|
+
* included, is what the trust receipt is keyed to and what selects our declaration below.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} launcher absolute path to the published launcher
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function entwurfDeclarationCommand(launcher) {
|
|
56
|
+
return `'${launcher}'`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The declaration this unit publishes, as a value. Three omissions carry meaning and are
|
|
60
|
+
* asserted as omissions everywhere downstream: no `matcher` (so all four SessionStart sources
|
|
61
|
+
* fire; `""` is NOT the same), no `async` (birth must be synchronous — the MCP child of the same
|
|
62
|
+
* turn resolves its identity from the record this hook writes), and no `state` (that key is the
|
|
63
|
+
* vendor's, in config.toml, and this unit never touches that file).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} launcher
|
|
66
|
+
* @returns {{hooks: Array<{type: string, command: string, timeout: number}>}}
|
|
67
|
+
*/
|
|
68
|
+
export function entwurfDeclarationGroup(launcher) {
|
|
69
|
+
return { hooks: [{ type: "command", command: entwurfDeclarationCommand(launcher), timeout: CODEX_BIRTH_TIMEOUT }] };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Canonical JSON: object keys sorted recursively, no insignificant whitespace. The ONLY reason
|
|
73
|
+
* this exists is so a digest of a declaration survives a neighbour re-serializing the document.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} value
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function canonicalJson(value) {
|
|
79
|
+
if (value === null || typeof value !== "object")
|
|
80
|
+
return JSON.stringify(value) ?? "null";
|
|
81
|
+
if (Array.isArray(value))
|
|
82
|
+
return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
|
|
83
|
+
const keys = Object.keys(value).sort();
|
|
84
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The digest that IS the certification. Taken over the event name together with the group, so a
|
|
88
|
+
* declaration moved to a different event is a different identity even when the group is equal.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} group
|
|
91
|
+
* @param {string} [event]
|
|
92
|
+
* @returns {string} sha256 hex
|
|
93
|
+
*/
|
|
94
|
+
export function declarationDigest(group, event = CODEX_BIRTH_EVENT) {
|
|
95
|
+
return createHash("sha256").update(canonicalJson({ event, group })).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The vendor's own trust key for a declaration at a given position.
|
|
99
|
+
* `[source]` discovery.rs:664-665 — `<declaration path>:<event>:<group_idx>:<handler_idx>`.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} hooksFile
|
|
102
|
+
* @param {number} groupIndex
|
|
103
|
+
* @param {number} handlerIndex
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function trustReceiptKey(hooksFile, groupIndex, handlerIndex) {
|
|
107
|
+
return `${hooksFile}:${CODEX_BIRTH_TRUST_EVENT}:${groupIndex}:${handlerIndex}`;
|
|
108
|
+
}
|
|
109
|
+
/** @typedef {{index: number, commands: string[]}} ForeignGroup */
|
|
110
|
+
/**
|
|
111
|
+
* Locate entwurf's declaration inside a parsed hooks document, and describe its neighbours.
|
|
112
|
+
*
|
|
113
|
+
* SELECTION IS BY THE LAUNCHER COMMAND, not by index and not by position. That string is the
|
|
114
|
+
* trust identity, so it is the only thing that can name our declaration in a file whose shape
|
|
115
|
+
* somebody else is also allowed to change. Shape is judged AFTER selection on purpose: a handler
|
|
116
|
+
* that carries our command and an extra `async` key is OUR declaration, drifted — a named red —
|
|
117
|
+
* rather than somebody else's group we failed to recognise.
|
|
118
|
+
*
|
|
119
|
+
* `foreign` is filled on EVERY outcome, including the failures, because the doctor reports
|
|
120
|
+
* neighbours whether or not our own bytes are intact.
|
|
121
|
+
*
|
|
122
|
+
* @param {unknown} parsed the parsed hooks.json document
|
|
123
|
+
* @param {string} launcher absolute launcher path
|
|
124
|
+
* @returns {{ok: true, groupIndex: number, handlerIndex: number, group: unknown, digest: string, foreign: ForeignGroup[]}
|
|
125
|
+
* | {ok: false, code: string, detail: string, groupIndex: number|null, foreign: ForeignGroup[]}}
|
|
126
|
+
*/
|
|
127
|
+
export function selectEntwurfDeclaration(parsed, launcher) {
|
|
128
|
+
const no = (code, detail, foreign = [], groupIndex = null) => ({ ok: false, code, detail, groupIndex, foreign });
|
|
129
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
130
|
+
return no("hooks-unreadable", "the hooks document is not a JSON object");
|
|
131
|
+
}
|
|
132
|
+
const events = parsed.hooks;
|
|
133
|
+
if (events === null || typeof events !== "object" || Array.isArray(events)) {
|
|
134
|
+
return no("hooks-unreadable", "the document carries no `hooks` object");
|
|
135
|
+
}
|
|
136
|
+
const groups = events[CODEX_BIRTH_EVENT];
|
|
137
|
+
if (groups === undefined)
|
|
138
|
+
return no("declaration-absent", `the document declares no ${CODEX_BIRTH_EVENT} group`);
|
|
139
|
+
if (!Array.isArray(groups))
|
|
140
|
+
return no("hooks-unreadable", `hooks.${CODEX_BIRTH_EVENT} is not an array`);
|
|
141
|
+
const want = entwurfDeclarationCommand(launcher);
|
|
142
|
+
/** @type {Array<{groupIndex: number, handlerIndex: number}>} */
|
|
143
|
+
const mine = [];
|
|
144
|
+
/** @type {ForeignGroup[]} */
|
|
145
|
+
const foreign = [];
|
|
146
|
+
for (let g = 0; g < groups.length; g += 1) {
|
|
147
|
+
const group = groups[g];
|
|
148
|
+
const handlers = group !== null && typeof group === "object" && !Array.isArray(group) ? group.hooks : undefined;
|
|
149
|
+
const list = Array.isArray(handlers) ? handlers : [];
|
|
150
|
+
let claimed = false;
|
|
151
|
+
for (let h = 0; h < list.length; h += 1) {
|
|
152
|
+
const handler = list[h];
|
|
153
|
+
if (handler !== null && typeof handler === "object" && !Array.isArray(handler) && handler.command === want) {
|
|
154
|
+
mine.push({ groupIndex: g, handlerIndex: h });
|
|
155
|
+
claimed = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!claimed) {
|
|
159
|
+
foreign.push({
|
|
160
|
+
index: g,
|
|
161
|
+
commands: list.map((handler) => handler !== null &&
|
|
162
|
+
typeof handler === "object" &&
|
|
163
|
+
!Array.isArray(handler) &&
|
|
164
|
+
typeof handler.command === "string"
|
|
165
|
+
? handler.command
|
|
166
|
+
: "(no command string)"),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (mine.length === 0) {
|
|
171
|
+
return no("declaration-absent", `no ${CODEX_BIRTH_EVENT} handler commands ${want}`, foreign);
|
|
172
|
+
}
|
|
173
|
+
if (mine.length > 1) {
|
|
174
|
+
const where = mine.map((m) => `group ${m.groupIndex} handler ${m.handlerIndex}`).join(", ");
|
|
175
|
+
return no("declaration-duplicated", `${mine.length} handlers command ${want} (${where}) — the vendor would run this birth hook more than once per session and only one position can carry the trust receipt`, foreign);
|
|
176
|
+
}
|
|
177
|
+
const { groupIndex, handlerIndex } = mine[0];
|
|
178
|
+
const group = groups[groupIndex];
|
|
179
|
+
const shape = declarationShapeProblem(group, want);
|
|
180
|
+
if (shape !== null)
|
|
181
|
+
return no("declaration-shape-drifted", shape, foreign, groupIndex);
|
|
182
|
+
return { ok: true, groupIndex, handlerIndex, group, digest: declarationDigest(group), foreign };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The GRAMMAR half of the certification, kept apart from the digest half because they fail for
|
|
186
|
+
* different reasons: this one says which keys may exist at all (an `async` or a `matcher` the
|
|
187
|
+
* installer never writes), while the digest says whether the VALUES are the ones recorded.
|
|
188
|
+
*
|
|
189
|
+
* @param {unknown} group
|
|
190
|
+
* @param {string} wantCommand
|
|
191
|
+
* @returns {string|null} a named problem, or null when the shape is exactly ours
|
|
192
|
+
*/
|
|
193
|
+
function declarationShapeProblem(group, wantCommand) {
|
|
194
|
+
if (group === null || typeof group !== "object" || Array.isArray(group))
|
|
195
|
+
return "the matcher group is not an object";
|
|
196
|
+
const keys = Object.keys(group).sort();
|
|
197
|
+
if (keys.join(",") !== "hooks") {
|
|
198
|
+
return `the matcher group must carry no key other than \`hooks\` (found ${keys.join(", ") || "none"}) — a \`matcher\` narrows which SessionStart sources fire and changes the identity the operator trusted`;
|
|
199
|
+
}
|
|
200
|
+
const handlers = group.hooks;
|
|
201
|
+
if (!Array.isArray(handlers) || handlers.length !== 1) {
|
|
202
|
+
return `the matcher group must hold exactly one handler (found ${Array.isArray(handlers) ? handlers.length : "a non-array"}) — a second handler in our own group is not a neighbour, it is our declaration edited`;
|
|
203
|
+
}
|
|
204
|
+
const handler = handlers[0];
|
|
205
|
+
if (handler === null || typeof handler !== "object" || Array.isArray(handler))
|
|
206
|
+
return "the handler is not an object";
|
|
207
|
+
const handlerKeys = Object.keys(handler).sort();
|
|
208
|
+
if (handlerKeys.join(",") !== "command,timeout,type") {
|
|
209
|
+
return `the handler must carry exactly type+command+timeout (found ${handlerKeys.join(", ") || "none"}) — every extra key, \`async\` above all, changes the trust identity`;
|
|
210
|
+
}
|
|
211
|
+
if (handler.type !== "command")
|
|
212
|
+
return `the handler type must be \`command\`, not ${JSON.stringify(handler.type)}`;
|
|
213
|
+
if (handler.command !== wantCommand)
|
|
214
|
+
return `the handler command is ${JSON.stringify(handler.command)}`;
|
|
215
|
+
if (handler.timeout !== CODEX_BIRTH_TIMEOUT) {
|
|
216
|
+
return `the handler timeout must be ${CODEX_BIRTH_TIMEOUT}, not ${JSON.stringify(handler.timeout)} — it is part of the identity the operator approved`;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/* ─────────────────────── span-aware reading, for byte-preserving edits ─────────────────────── */
|
|
221
|
+
/**
|
|
222
|
+
* A JSON reader that keeps every node's text span. Deliberately NOT a tolerant parser: it is the
|
|
223
|
+
* same grammar `JSON.parse` accepts, and every caller below cross-checks its value against
|
|
224
|
+
* `JSON.parse` before any file is written, so a disagreement is a refusal rather than an edit.
|
|
225
|
+
*
|
|
226
|
+
* @typedef {{value: unknown, start: number, end: number, elements?: SpanNode[], members?: Array<{key: string, start: number, end: number, value: SpanNode}>}} SpanNode
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} text
|
|
230
|
+
* @returns {SpanNode}
|
|
231
|
+
*/
|
|
232
|
+
export function parseWithSpans(text) {
|
|
233
|
+
let at = 0;
|
|
234
|
+
const fail = (why) => {
|
|
235
|
+
throw new Error(`hooks.json is not readable at offset ${at}: ${why}`);
|
|
236
|
+
};
|
|
237
|
+
const ws = () => {
|
|
238
|
+
while (at < text.length && (text[at] === " " || text[at] === "\t" || text[at] === "\n" || text[at] === "\r"))
|
|
239
|
+
at += 1;
|
|
240
|
+
};
|
|
241
|
+
const lit = (word, value) => {
|
|
242
|
+
if (text.slice(at, at + word.length) !== word)
|
|
243
|
+
fail(`expected ${word}`);
|
|
244
|
+
const start = at;
|
|
245
|
+
at += word.length;
|
|
246
|
+
return { value, start, end: at };
|
|
247
|
+
};
|
|
248
|
+
const str = () => {
|
|
249
|
+
const start = at;
|
|
250
|
+
if (text[at] !== '"')
|
|
251
|
+
fail("expected a string");
|
|
252
|
+
at += 1;
|
|
253
|
+
while (at < text.length) {
|
|
254
|
+
const ch = text[at];
|
|
255
|
+
if (ch === "\\") {
|
|
256
|
+
at += 2;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === '"') {
|
|
260
|
+
at += 1;
|
|
261
|
+
return { value: JSON.parse(text.slice(start, at)), start, end: at };
|
|
262
|
+
}
|
|
263
|
+
at += 1;
|
|
264
|
+
}
|
|
265
|
+
return fail("unterminated string");
|
|
266
|
+
};
|
|
267
|
+
const num = () => {
|
|
268
|
+
const start = at;
|
|
269
|
+
if (text[at] === "-")
|
|
270
|
+
at += 1;
|
|
271
|
+
while (at < text.length && /[0-9eE+.-]/.test(text[at]))
|
|
272
|
+
at += 1;
|
|
273
|
+
const raw = text.slice(start, at);
|
|
274
|
+
const value = Number(raw);
|
|
275
|
+
if (raw.length === 0 || !Number.isFinite(value))
|
|
276
|
+
fail("expected a number");
|
|
277
|
+
return { value, start, end: at };
|
|
278
|
+
};
|
|
279
|
+
/** @returns {SpanNode} */
|
|
280
|
+
const node = () => {
|
|
281
|
+
ws();
|
|
282
|
+
const ch = text[at];
|
|
283
|
+
if (ch === "{") {
|
|
284
|
+
const start = at;
|
|
285
|
+
at += 1;
|
|
286
|
+
/** @type {Array<{key: string, start: number, end: number, value: SpanNode}>} */
|
|
287
|
+
const members = [];
|
|
288
|
+
/** @type {Record<string, unknown>} */
|
|
289
|
+
const value = {};
|
|
290
|
+
ws();
|
|
291
|
+
if (text[at] === "}") {
|
|
292
|
+
at += 1;
|
|
293
|
+
return { value, start, end: at, members };
|
|
294
|
+
}
|
|
295
|
+
for (;;) {
|
|
296
|
+
ws();
|
|
297
|
+
const memberStart = at;
|
|
298
|
+
const key = str();
|
|
299
|
+
ws();
|
|
300
|
+
if (text[at] !== ":")
|
|
301
|
+
fail("expected ':'");
|
|
302
|
+
at += 1;
|
|
303
|
+
const child = node();
|
|
304
|
+
members.push({ key: String(key.value), start: memberStart, end: child.end, value: child });
|
|
305
|
+
value[String(key.value)] = child.value;
|
|
306
|
+
ws();
|
|
307
|
+
if (text[at] === ",") {
|
|
308
|
+
at += 1;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (text[at] === "}") {
|
|
312
|
+
at += 1;
|
|
313
|
+
return { value, start, end: at, members };
|
|
314
|
+
}
|
|
315
|
+
return fail("expected ',' or '}'");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (ch === "[") {
|
|
319
|
+
const start = at;
|
|
320
|
+
at += 1;
|
|
321
|
+
/** @type {SpanNode[]} */
|
|
322
|
+
const elements = [];
|
|
323
|
+
ws();
|
|
324
|
+
if (text[at] === "]") {
|
|
325
|
+
at += 1;
|
|
326
|
+
return { value: [], start, end: at, elements };
|
|
327
|
+
}
|
|
328
|
+
for (;;) {
|
|
329
|
+
const child = node();
|
|
330
|
+
elements.push(child);
|
|
331
|
+
ws();
|
|
332
|
+
if (text[at] === ",") {
|
|
333
|
+
at += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (text[at] === "]") {
|
|
337
|
+
at += 1;
|
|
338
|
+
return { value: elements.map((e) => e.value), start, end: at, elements };
|
|
339
|
+
}
|
|
340
|
+
return fail("expected ',' or ']'");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (ch === '"')
|
|
344
|
+
return str();
|
|
345
|
+
if (ch === "t")
|
|
346
|
+
return lit("true", true);
|
|
347
|
+
if (ch === "f")
|
|
348
|
+
return lit("false", false);
|
|
349
|
+
if (ch === "n")
|
|
350
|
+
return lit("null", null);
|
|
351
|
+
return num();
|
|
352
|
+
};
|
|
353
|
+
const root = node();
|
|
354
|
+
ws();
|
|
355
|
+
if (at !== text.length)
|
|
356
|
+
fail("trailing content after the document");
|
|
357
|
+
return root;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The span of `hooks.SessionStart` and of each group inside it.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} text
|
|
363
|
+
* @returns {{root: SpanNode, array: SpanNode}}
|
|
364
|
+
*/
|
|
365
|
+
export function sessionStartSpans(text) {
|
|
366
|
+
const root = parseWithSpans(text);
|
|
367
|
+
const hooks = root.members?.find((m) => m.key === "hooks");
|
|
368
|
+
if (!hooks)
|
|
369
|
+
throw new Error("the document carries no `hooks` object");
|
|
370
|
+
const event = hooks.value.members?.find((m) => m.key === CODEX_BIRTH_EVENT);
|
|
371
|
+
if (!event)
|
|
372
|
+
throw new Error(`the document carries no hooks.${CODEX_BIRTH_EVENT} array`);
|
|
373
|
+
if (!Array.isArray(event.value.value))
|
|
374
|
+
throw new Error(`hooks.${CODEX_BIRTH_EVENT} is not an array`);
|
|
375
|
+
return { root, array: event.value };
|
|
376
|
+
}
|
|
377
|
+
/** The whitespace a given offset is indented BY — non-empty only when the offset opens its own
|
|
378
|
+
* line, which is what makes it the right indent to repeat for a sibling element. */
|
|
379
|
+
function indentAt(text, offset) {
|
|
380
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
381
|
+
const line = text.slice(lineStart, offset);
|
|
382
|
+
return /^[ \t]*$/.test(line) ? line : "";
|
|
383
|
+
}
|
|
384
|
+
/** The whitespace opening the LINE a given offset sits on, whatever else is on it — used when an
|
|
385
|
+
* insertion has no sibling to line up with and must derive its indent from its container. */
|
|
386
|
+
function lineIndentAt(text, offset) {
|
|
387
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
388
|
+
return /^[ \t]*/.exec(text.slice(lineStart, offset))?.[0] ?? "";
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Remove ONE element of the SessionStart array, editing nothing else in the file.
|
|
392
|
+
*
|
|
393
|
+
* The separator is removed with the element it belongs to: a middle or last element takes the
|
|
394
|
+
* comma BEFORE it (and the whitespace back to its predecessor), the first takes the comma after
|
|
395
|
+
* it, and a sole element leaves an empty array. Every neighbour's bytes are copied through
|
|
396
|
+
* untouched — that is the whole point of this function existing instead of a re-serialize.
|
|
397
|
+
*
|
|
398
|
+
* @param {string} text
|
|
399
|
+
* @param {number} index
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
export function removeSessionStartGroup(text, index) {
|
|
403
|
+
const { array } = sessionStartSpans(text);
|
|
404
|
+
const elements = array.elements ?? [];
|
|
405
|
+
if (index < 0 || index >= elements.length)
|
|
406
|
+
throw new Error(`no SessionStart group at index ${index}`);
|
|
407
|
+
let cutStart;
|
|
408
|
+
let cutEnd;
|
|
409
|
+
if (elements.length === 1) {
|
|
410
|
+
cutStart = array.start + 1;
|
|
411
|
+
cutEnd = array.end - 1;
|
|
412
|
+
}
|
|
413
|
+
else if (index === 0) {
|
|
414
|
+
cutStart = elements[0].start;
|
|
415
|
+
cutEnd = elements[1].start;
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
cutStart = elements[index - 1].end;
|
|
419
|
+
cutEnd = elements[index].end;
|
|
420
|
+
}
|
|
421
|
+
return text.slice(0, cutStart) + text.slice(cutEnd);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Give a document that has a `hooks` object but no `SessionStart` array an EMPTY one, editing
|
|
425
|
+
* nothing else. Without this, appending our group into a file whose owner declared only other
|
|
426
|
+
* events would have to re-serialize the document — the exact foreign-byte rewrite this module
|
|
427
|
+
* exists to avoid. A document with no `hooks` object at all is somebody else's grammar and is
|
|
428
|
+
* refused by the caller, not repaired here.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} text
|
|
431
|
+
* @returns {string}
|
|
432
|
+
*/
|
|
433
|
+
export function ensureSessionStartArray(text) {
|
|
434
|
+
const root = parseWithSpans(text);
|
|
435
|
+
const hooks = root.members?.find((m) => m.key === "hooks");
|
|
436
|
+
if (!hooks)
|
|
437
|
+
throw new Error("the document carries no `hooks` object");
|
|
438
|
+
if (hooks.value.members?.some((m) => m.key === CODEX_BIRTH_EVENT))
|
|
439
|
+
return text;
|
|
440
|
+
const members = hooks.value.members ?? [];
|
|
441
|
+
const fresh = `${JSON.stringify(CODEX_BIRTH_EVENT)}: []`;
|
|
442
|
+
if (members.length === 0) {
|
|
443
|
+
const base = lineIndentAt(text, hooks.value.start);
|
|
444
|
+
return `${text.slice(0, hooks.value.start + 1)}\n${base} ${fresh}\n${base}${text.slice(hooks.value.end - 1)}`;
|
|
445
|
+
}
|
|
446
|
+
const last = members[members.length - 1];
|
|
447
|
+
const indent = indentAt(text, last.start);
|
|
448
|
+
return `${text.slice(0, last.end)},\n${indent}${fresh}${text.slice(last.end)}`;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Append our group to the SessionStart array, editing nothing else in the file.
|
|
452
|
+
*
|
|
453
|
+
* APPEND, never insert: the vendor keys trust by index, so taking a position a neighbour already
|
|
454
|
+
* holds would renumber THEIR receipt and cost the operator an approval they already gave.
|
|
455
|
+
*
|
|
456
|
+
* @param {string} text
|
|
457
|
+
* @param {unknown} group
|
|
458
|
+
* @returns {string}
|
|
459
|
+
*/
|
|
460
|
+
export function appendSessionStartGroup(text, group) {
|
|
461
|
+
const { array } = sessionStartSpans(text);
|
|
462
|
+
const elements = array.elements ?? [];
|
|
463
|
+
const body = JSON.stringify(group, null, 2);
|
|
464
|
+
if (elements.length === 0) {
|
|
465
|
+
const base = lineIndentAt(text, array.start);
|
|
466
|
+
const laid = body
|
|
467
|
+
.split("\n")
|
|
468
|
+
.map((line) => `${base} ${line}`)
|
|
469
|
+
.join("\n");
|
|
470
|
+
return `${text.slice(0, array.start + 1)}\n${laid}\n${base}${text.slice(array.end - 1)}`;
|
|
471
|
+
}
|
|
472
|
+
const last = elements[elements.length - 1];
|
|
473
|
+
const indent = indentAt(text, last.start);
|
|
474
|
+
const laid = body
|
|
475
|
+
.split("\n")
|
|
476
|
+
.map((line, i) => (i === 0 ? line : indent + line))
|
|
477
|
+
.join("\n");
|
|
478
|
+
return `${text.slice(0, last.end)},\n${indent}${laid}${text.slice(last.end)}`;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Remove a top-level `description` whose value is entwurf's own prose. Ours to remove, and only
|
|
482
|
+
* ours: a description we did not author is a foreign byte and is left exactly where it is.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} text
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
export function removeEntwurfDescription(text) {
|
|
488
|
+
const root = parseWithSpans(text);
|
|
489
|
+
const members = root.members ?? [];
|
|
490
|
+
const index = members.findIndex((m) => m.key === "description" &&
|
|
491
|
+
typeof m.value.value === "string" &&
|
|
492
|
+
m.value.value.startsWith(CODEX_BIRTH_DESCRIPTION_PREFIX));
|
|
493
|
+
if (index === -1)
|
|
494
|
+
return text;
|
|
495
|
+
let cutStart;
|
|
496
|
+
let cutEnd;
|
|
497
|
+
if (members.length === 1) {
|
|
498
|
+
cutStart = root.start + 1;
|
|
499
|
+
cutEnd = root.end - 1;
|
|
500
|
+
}
|
|
501
|
+
else if (index === 0) {
|
|
502
|
+
cutStart = members[0].start;
|
|
503
|
+
cutEnd = members[1].start;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
cutStart = members[index - 1].end;
|
|
507
|
+
cutEnd = members[index].end;
|
|
508
|
+
}
|
|
509
|
+
return text.slice(0, cutStart) + text.slice(cutEnd);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* THE POST-CONDITION EVERY SPLICE IS GATED ON. The span reader above is the only new way this
|
|
513
|
+
* unit can damage a file it does not own, so no splice is ever returned to a writer on the
|
|
514
|
+
* strength of the reader alone: the result is re-parsed with `JSON.parse` and deep-compared to
|
|
515
|
+
* the value the caller says it intended. A splice that lands anywhere else throws, and every
|
|
516
|
+
* caller turns that into a zero-write refusal.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} spliced
|
|
519
|
+
* @param {unknown} expected
|
|
520
|
+
* @returns {string} the same text, once it is proven to mean exactly `expected`
|
|
521
|
+
*/
|
|
522
|
+
export function certifySplice(spliced, expected) {
|
|
523
|
+
let reparsed;
|
|
524
|
+
try {
|
|
525
|
+
reparsed = JSON.parse(spliced);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
throw new Error(`the edited hooks document does not parse (${err instanceof Error ? err.message : String(err)}); nothing written`);
|
|
529
|
+
}
|
|
530
|
+
if (canonicalJson(reparsed) !== canonicalJson(expected)) {
|
|
531
|
+
throw new Error("the edited hooks document is not the value this edit intended — the span edit landed somewhere else; nothing written");
|
|
532
|
+
}
|
|
533
|
+
return spliced;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Is this shared file one we may write through, and is it provably ours to certify?
|
|
537
|
+
*
|
|
538
|
+
* WHY IT LIVES HERE (sol B3, 2026-09-18). Four surfaces decide about the SAME file — the
|
|
539
|
+
* installer, the inverse, the doctor and the fresh-call preflight — and they were deciding
|
|
540
|
+
* differently. Preflight required a plausible owner and refused group/world-writable
|
|
541
|
+
* (`codex-fresh-preflight.ts`); the two shells checked only symlink-and-regular, and the doctor
|
|
542
|
+
* checked the file's CONTENT without ever asking who owned it. So a hooks.json owned by another
|
|
543
|
+
* uid, or writable by a group, could be installed into and reported GREEN while every Codex fresh
|
|
544
|
+
* call refused it as `codex-birth-unit-missing` — install and doctor saying yes about the same
|
|
545
|
+
* bytes launch said no about, which is the split this closes.
|
|
546
|
+
*
|
|
547
|
+
* TWO DIFFERENT THINGS, and only one of them is ours. We do NOT chmod a file we share — a
|
|
548
|
+
* neighbour's mode is a neighbour's business, and the installer carries it over untouched. But
|
|
549
|
+
* WRITING INTO a file anyone else can rewrite is a different question: whatever we certify there,
|
|
550
|
+
* someone else can change afterwards, so our receipt would describe bytes we cannot bind. That is
|
|
551
|
+
* why an unsafe file is a zero-write refusal rather than a mode we normalize.
|
|
552
|
+
*
|
|
553
|
+
* The classifier is PURE — it judges a stat record, not a path — so the same rules can be proven
|
|
554
|
+
* against fixture records with no filesystem, and so this leaf keeps its "dependency-free except
|
|
555
|
+
* node:crypto" promise. `statOwnedPath` takes the `fs` module from its caller for the same reason.
|
|
556
|
+
*
|
|
557
|
+
* @param {{ exists: boolean, isSymbolicLink: boolean, isFile: boolean, isDirectory: boolean, uid: number, mode: number }} stat
|
|
558
|
+
* @param {number} expectedUid
|
|
559
|
+
* @param {{ kind?: "file" | "directory" }} [options]
|
|
560
|
+
* @returns {"ok"|"missing"|"symlink"|"not-regular"|"foreign-uid"|"writable-by-others"}
|
|
561
|
+
*/
|
|
562
|
+
export function classifyOwnedPath(stat, expectedUid, options = {}) {
|
|
563
|
+
const kind = options.kind ?? "file";
|
|
564
|
+
if (!stat.exists)
|
|
565
|
+
return "missing";
|
|
566
|
+
// SYMLINK FIRST, and it is not folded into `not-regular`: the two repairs differ. A link's
|
|
567
|
+
// target could be any file on the host, so the answer is never "fix the mode" — it is "this
|
|
568
|
+
// path is not the file we think we are looking at".
|
|
569
|
+
if (stat.isSymbolicLink)
|
|
570
|
+
return "symlink";
|
|
571
|
+
if (kind === "directory" ? !stat.isDirectory : !stat.isFile)
|
|
572
|
+
return "not-regular";
|
|
573
|
+
if (stat.uid !== expectedUid)
|
|
574
|
+
return "foreign-uid";
|
|
575
|
+
if ((stat.mode & 0o022) !== 0)
|
|
576
|
+
return "writable-by-others";
|
|
577
|
+
return "ok";
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Read one path into the record `classifyOwnedPath` judges. `lstat`, never `stat`: following the
|
|
581
|
+
* link would answer about its target and hide the one verdict whose repair is different.
|
|
582
|
+
*
|
|
583
|
+
* @param {{ lstatSync: (p: string) => { isFile: () => boolean, isDirectory: () => boolean, isSymbolicLink: () => boolean, uid: number, mode: number } }} fs
|
|
584
|
+
* @param {string} file
|
|
585
|
+
*/
|
|
586
|
+
export function statOwnedPath(fs, file) {
|
|
587
|
+
try {
|
|
588
|
+
const st = fs.lstatSync(file);
|
|
589
|
+
return {
|
|
590
|
+
exists: true,
|
|
591
|
+
isSymbolicLink: st.isSymbolicLink(),
|
|
592
|
+
isFile: st.isFile(),
|
|
593
|
+
isDirectory: st.isDirectory(),
|
|
594
|
+
uid: st.uid,
|
|
595
|
+
mode: st.mode,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return { exists: false, isSymbolicLink: false, isFile: false, isDirectory: false, uid: -1, mode: 0 };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/** What each verdict means to whoever has to repair it. One wording, so the installer, the
|
|
603
|
+
* inverse and the doctor cannot describe the same file in three different ways. */
|
|
604
|
+
export const OWNED_PATH_REFUSAL = {
|
|
605
|
+
symlink: "is a SYMLINK — this unit publishes through no link, and a link's target could be any file on the host",
|
|
606
|
+
"not-regular": "is not a regular file",
|
|
607
|
+
"foreign-uid": "is owned by another user, so nothing here can bind what it will say next",
|
|
608
|
+
"writable-by-others": "is group/world-writable, so anything certified in it can be rewritten by someone else afterwards",
|
|
609
|
+
};
|