@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
|
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { parse } from "smol-toml";
|
|
5
|
+
import { CODEX_BIRTH_EVENT, classifyOwnedPath, entwurfDeclarationCommand, selectEntwurfDeclaration, statOwnedPath, trustReceiptKey, } from "./codex-declaration.js";
|
|
5
6
|
import { checkCodexSocketFile, realCodexProtocolOpener, resolveCodexDefaultSocketPath, } from "./native-push/codex-ws-client.js";
|
|
6
7
|
export const CODEX_PREFLIGHT_HINT = {
|
|
7
8
|
"codex-birth-unit-missing": "the Codex SessionStart birth unit is absent, drifted from its recorded digests, or unsafely owned; run `entwurf install-codex-birth`, then `entwurf doctor-codex-birth`",
|
|
@@ -44,54 +45,69 @@ const CODEX_MCP_ENV_VARS = [
|
|
|
44
45
|
"TMUX",
|
|
45
46
|
"TMUX_PANE",
|
|
46
47
|
];
|
|
48
|
+
/**
|
|
49
|
+
* ONE POLICY, FOUR SURFACES. The predicate itself lives in `codex-declaration.js` (sol B3,
|
|
50
|
+
* 2026-09-18) because the installer, the inverse and the doctor decide about the SAME shared
|
|
51
|
+
* hooks.json and used to decide differently: they checked symlink-and-regular, this checked owner
|
|
52
|
+
* and mode as well, and the doctor checked neither. That split let an install and a doctor say yes
|
|
53
|
+
* about bytes every fresh call then refused. The executable bit stays HERE — it is this rail's
|
|
54
|
+
* question about the launcher codex is going to exec, not a fact about shared ownership.
|
|
55
|
+
*/
|
|
47
56
|
function isSafeOwnedFile(file, expectedUid, executable = false) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return (stat.isFile() &&
|
|
51
|
-
!stat.isSymbolicLink() &&
|
|
52
|
-
stat.uid === expectedUid &&
|
|
53
|
-
(stat.mode & 0o022) === 0 &&
|
|
54
|
-
(!executable || (stat.mode & 0o111) !== 0));
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
+
const stat = statOwnedPath(fs, file);
|
|
58
|
+
if (classifyOwnedPath(stat, expectedUid) !== "ok")
|
|
57
59
|
return false;
|
|
58
|
-
|
|
60
|
+
return !executable || (stat.mode & 0o111) !== 0;
|
|
59
61
|
}
|
|
60
62
|
function isSafeOwnedDir(dir, expectedUid) {
|
|
61
|
-
|
|
62
|
-
const stat = fs.lstatSync(dir);
|
|
63
|
-
return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
|
|
64
|
-
}
|
|
65
|
-
catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
63
|
+
return classifyOwnedPath(statOwnedPath(fs, dir), expectedUid, { kind: "directory" }) === "ok";
|
|
68
64
|
}
|
|
69
65
|
/**
|
|
70
66
|
* The unit's own ownership state, read as the digest inventory it is. Bytes that merely
|
|
71
67
|
* EXIST prove nothing: the launcher codex is about to exec must be the one this unit
|
|
72
68
|
* published, so every recorded member is compared to its recorded digest before a sibling
|
|
73
69
|
* is opened. No digest is computed for the VENDOR here — that is a different axis below.
|
|
70
|
+
*
|
|
71
|
+
* WHAT THIS NO LONGER READS (#117): a WHOLE-FILE `hooksSha256`. `codex-birth-install-state/v2`
|
|
72
|
+
* records the normalized digest of entwurf's own declaration instead, because hooks.json is a
|
|
73
|
+
* file this unit SHARES — a neighbouring integration appending its own `SessionStart` group
|
|
74
|
+
* changes every byte of that file and none of our declaration's meaning. The declaration axis
|
|
75
|
+
* lives in `birthDeclaration` below, which needs the live document anyway to find where our group
|
|
76
|
+
* currently sits.
|
|
74
77
|
*/
|
|
75
|
-
function
|
|
78
|
+
function certifiedState(paths, expectedUid) {
|
|
76
79
|
// The directory holding the state carries the state's authority: anyone who can write it
|
|
77
80
|
// can replace the inventory every digest below is compared against.
|
|
78
81
|
if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid))
|
|
79
|
-
return
|
|
82
|
+
return null;
|
|
80
83
|
if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid))
|
|
81
|
-
return
|
|
84
|
+
return null;
|
|
82
85
|
if (!isSafeOwnedFile(paths.stateFile, expectedUid))
|
|
83
|
-
return
|
|
86
|
+
return null;
|
|
84
87
|
let state;
|
|
85
88
|
try {
|
|
86
89
|
state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8"));
|
|
87
90
|
}
|
|
88
91
|
catch {
|
|
89
|
-
return
|
|
92
|
+
return null;
|
|
90
93
|
}
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
// v2 ONLY, and a v1 state is refused rather than read leniently: v1 recorded a whole-file
|
|
95
|
+
// digest, so a reader that tolerated it would be certifying an authority nobody holds any
|
|
96
|
+
// more. `entwurf install-codex-birth` supersedes it — that is the one forward path.
|
|
97
|
+
if (state.schema !== "codex-birth-install-state/v2" || state.status !== "installed")
|
|
98
|
+
return null;
|
|
93
99
|
if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir)
|
|
94
|
-
return
|
|
100
|
+
return null;
|
|
101
|
+
const declaration = state.declaration;
|
|
102
|
+
if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration))
|
|
103
|
+
return null;
|
|
104
|
+
const recorded = declaration;
|
|
105
|
+
if (recorded.event !== CODEX_BIRTH_EVENT)
|
|
106
|
+
return null;
|
|
107
|
+
if (typeof recorded.command !== "string" || typeof recorded.sha256 !== "string")
|
|
108
|
+
return null;
|
|
109
|
+
if (!/^[0-9a-f]{64}$/.test(recorded.sha256))
|
|
110
|
+
return null;
|
|
95
111
|
const digest = (file) => {
|
|
96
112
|
try {
|
|
97
113
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -100,11 +116,9 @@ function closureDriftedFromState(paths, expectedUid) {
|
|
|
100
116
|
return null;
|
|
101
117
|
}
|
|
102
118
|
};
|
|
103
|
-
if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256)
|
|
104
|
-
return true;
|
|
105
119
|
const members = state.helperFiles;
|
|
106
120
|
if (!Array.isArray(members))
|
|
107
|
-
return
|
|
121
|
+
return null;
|
|
108
122
|
// Exactly the closure the installer publishes: a short inventory would leave a member
|
|
109
123
|
// nothing compares, which is the same hole as not checking digests at all.
|
|
110
124
|
const named = members.map((raw) => raw?.path);
|
|
@@ -117,23 +131,37 @@ function closureDriftedFromState(paths, expectedUid) {
|
|
|
117
131
|
"entwurf-capabilities.json",
|
|
118
132
|
];
|
|
119
133
|
if (named.length !== expected.length)
|
|
120
|
-
return
|
|
134
|
+
return null;
|
|
121
135
|
if (expected.some((name) => !named.includes(name)))
|
|
122
|
-
return
|
|
136
|
+
return null;
|
|
123
137
|
if (named.some((name, index) => named.indexOf(name) !== index))
|
|
124
|
-
return
|
|
138
|
+
return null;
|
|
125
139
|
for (const raw of members) {
|
|
126
140
|
const member = raw;
|
|
127
141
|
const rel = member.path;
|
|
128
142
|
if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
|
|
129
|
-
return
|
|
143
|
+
return null;
|
|
130
144
|
}
|
|
131
145
|
if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256)
|
|
132
|
-
return
|
|
146
|
+
return null;
|
|
133
147
|
}
|
|
134
|
-
return
|
|
148
|
+
return { command: recorded.command, sha256: recorded.sha256 };
|
|
135
149
|
}
|
|
136
|
-
|
|
150
|
+
/**
|
|
151
|
+
* WHERE ENTWURF'S DECLARATION CURRENTLY SITS, or nothing.
|
|
152
|
+
*
|
|
153
|
+
* `null` here means the SAME reject as before — `codex-birth-unit-missing` — but the question
|
|
154
|
+
* it answers is narrower than it used to be (#117). It no longer asks "is hooks.json exactly the
|
|
155
|
+
* file we wrote"; it asks "is entwurf's own `SessionStart` group present exactly once, shaped the
|
|
156
|
+
* way the operator approved, and still the declaration this unit recorded". A Herdr (or any
|
|
157
|
+
* other) integration declaring its own group beside ours changes neither answer.
|
|
158
|
+
*
|
|
159
|
+
* The POSITION is the return value rather than a side note because the vendor keys its trust
|
|
160
|
+
* receipt by index: a declaration that has been renumbered by a neighbour's install needs its
|
|
161
|
+
* receipt read at the new index, and reading the old one would report a NEIGHBOUR's approval as
|
|
162
|
+
* our own.
|
|
163
|
+
*/
|
|
164
|
+
function birthDeclaration(paths, expectedUid) {
|
|
137
165
|
const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
|
|
138
166
|
const directories = [
|
|
139
167
|
path.dirname(paths.hooksFile),
|
|
@@ -142,9 +170,9 @@ function birthMissing(paths, expectedUid) {
|
|
|
142
170
|
path.join(paths.helperDir, "lib", "native-push"),
|
|
143
171
|
];
|
|
144
172
|
if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid)))
|
|
145
|
-
return
|
|
173
|
+
return null;
|
|
146
174
|
if (!isSafeOwnedFile(paths.hooksFile, expectedUid))
|
|
147
|
-
return
|
|
175
|
+
return null;
|
|
148
176
|
const closure = [
|
|
149
177
|
[launcher, true],
|
|
150
178
|
[path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
|
|
@@ -154,25 +182,28 @@ function birthMissing(paths, expectedUid) {
|
|
|
154
182
|
[path.join(paths.helperDir, "entwurf-capabilities.json"), false],
|
|
155
183
|
];
|
|
156
184
|
if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable)))
|
|
157
|
-
return
|
|
185
|
+
return null;
|
|
186
|
+
const recorded = certifiedState(paths, expectedUid);
|
|
187
|
+
if (recorded === null)
|
|
188
|
+
return null;
|
|
189
|
+
// The state's own account of what it certified must be the declaration these paths produce;
|
|
190
|
+
// a receipt bound to a different launcher is a receipt for a different unit.
|
|
191
|
+
if (recorded.command !== entwurfDeclarationCommand(launcher))
|
|
192
|
+
return null;
|
|
193
|
+
let selected;
|
|
158
194
|
try {
|
|
159
|
-
|
|
160
|
-
const events = parsed.hooks?.SessionStart;
|
|
161
|
-
if (!Array.isArray(events) || events.length !== 1)
|
|
162
|
-
return true;
|
|
163
|
-
const group = events[0];
|
|
164
|
-
if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1)
|
|
165
|
-
return true;
|
|
166
|
-
const hook = group.hooks[0];
|
|
167
|
-
if (Object.keys(hook).sort().join(",") !== "command,timeout,type")
|
|
168
|
-
return true;
|
|
169
|
-
if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30)
|
|
170
|
-
return true;
|
|
195
|
+
selected = selectEntwurfDeclaration(JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")), launcher);
|
|
171
196
|
}
|
|
172
197
|
catch {
|
|
173
|
-
return
|
|
198
|
+
return null;
|
|
174
199
|
}
|
|
175
|
-
|
|
200
|
+
if (!selected.ok)
|
|
201
|
+
return null;
|
|
202
|
+
// The NORMALIZED digest, so a neighbour re-serializing the document (measured: Herdr does)
|
|
203
|
+
// cannot make our intact declaration look edited — and an edit to our own handler still can.
|
|
204
|
+
if (selected.digest !== recorded.sha256)
|
|
205
|
+
return null;
|
|
206
|
+
return { groupIndex: selected.groupIndex, handlerIndex: selected.handlerIndex };
|
|
176
207
|
}
|
|
177
208
|
/**
|
|
178
209
|
* The vendor's own receipt for THIS declaration, read and never written. The key the vendor
|
|
@@ -180,15 +211,21 @@ function birthMissing(paths, expectedUid) {
|
|
|
180
211
|
* `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
|
|
181
212
|
* for our declaration identity — never that the hash is correct, which only the vendor can
|
|
182
213
|
* say, and never by launching Codex to find out.
|
|
214
|
+
*
|
|
215
|
+
* THE KEY IS MEASURED, NOT ASSUMED (#117). It used to be spelled `…:session_start:0:0`, which
|
|
216
|
+
* was true only while entwurf was the sole declaration in the file. With a neighbour at index 0
|
|
217
|
+
* that constant reads THEIR receipt and calls it ours — a false green for a hook the vendor has
|
|
218
|
+
* never been asked to run. The caller passes the key built from the position our declaration was
|
|
219
|
+
* just found at.
|
|
183
220
|
*/
|
|
184
|
-
function trustReceiptMissing(config,
|
|
221
|
+
function trustReceiptMissing(config, key) {
|
|
185
222
|
const hooks = config.hooks;
|
|
186
223
|
if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks))
|
|
187
224
|
return true;
|
|
188
225
|
const state = hooks.state;
|
|
189
226
|
if (state == null || typeof state !== "object" || Array.isArray(state))
|
|
190
227
|
return true;
|
|
191
|
-
const entry = state[
|
|
228
|
+
const entry = state[key];
|
|
192
229
|
if (entry == null || typeof entry !== "object" || Array.isArray(entry))
|
|
193
230
|
return true;
|
|
194
231
|
const digest = entry.trusted_hash;
|
|
@@ -497,14 +534,16 @@ export async function codexFreshPreflight(env, deps = {}) {
|
|
|
497
534
|
if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
|
|
498
535
|
return "codex-birth-unit-missing";
|
|
499
536
|
}
|
|
500
|
-
|
|
537
|
+
const declaration = birthDeclaration(paths, deps.operatorUid ?? process.getuid?.() ?? -1);
|
|
538
|
+
if (declaration === null) {
|
|
501
539
|
return "codex-birth-unit-missing";
|
|
502
540
|
}
|
|
503
541
|
const config = readConfig(env);
|
|
504
542
|
// The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
|
|
505
543
|
// not run and absent bytes are two different repairs, and folding them would send the
|
|
506
544
|
// operator to the installer for something only they can answer in their own Codex.
|
|
507
|
-
|
|
545
|
+
const key = trustReceiptKey(paths.hooksFile, declaration.groupIndex, declaration.handlerIndex);
|
|
546
|
+
if (config === null || trustReceiptMissing(config, key))
|
|
508
547
|
return "codex-birth-trust-missing";
|
|
509
548
|
if (mcpMissing(config))
|
|
510
549
|
return "codex-mcp-hand-missing";
|
|
@@ -38,6 +38,46 @@ export function formatSenderInfoBlock(sender, wantsReply = false) {
|
|
|
38
38
|
...(wantsReply ? { wants_reply: true } : {}),
|
|
39
39
|
})}</sender_info>`;
|
|
40
40
|
}
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Accepted-connection disconnect policy (server half of the same wire)
|
|
43
|
+
// ============================================================================
|
|
44
|
+
/** The two codes that mean THE PEER WENT AWAY, and nothing else. `EPIPE` is a write to a pipe the
|
|
45
|
+
* far side already closed; `ECONNRESET` is the far side resetting it. Both describe the client, not
|
|
46
|
+
* this process's state, which is why they are absorbed rather than diagnosed. */
|
|
47
|
+
const PEER_DISCONNECT_CODES = new Set(["EPIPE", "ECONNRESET"]);
|
|
48
|
+
export function isPeerDisconnect(error) {
|
|
49
|
+
return typeof error.code === "string" && PEER_DISCONNECT_CODES.has(error.code);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Install the disconnect policy on a connection this process ACCEPTED. Must run before any data
|
|
53
|
+
* handler and before any response can be written back.
|
|
54
|
+
*
|
|
55
|
+
* `[측정 2026-09-14, .agent-reports/116-c4-live-blocker-20260914.md]` a resident pi DIED without
|
|
56
|
+
* this. A sibling's control-socket send timed out while the receiving session was mid-turn, the
|
|
57
|
+
* sender closed its end, and the server then wrote its late response to a socket whose peer was
|
|
58
|
+
* gone. That EPIPE does NOT arrive as a throw — `writeResponse`'s synchronous try/catch cannot see
|
|
59
|
+
* it — it arrives asynchronously as an `error` event, and an `error` event with no listener is an
|
|
60
|
+
* uncaught exception, so Node terminated the whole session. The citizen then simply read as `dead`
|
|
61
|
+
* and the stale socket it left behind was the only trace.
|
|
62
|
+
*
|
|
63
|
+
* A client that went away is a BOUNDED environment condition, not invalid state: Rule 15's "crash,
|
|
64
|
+
* don't warn" governs states we cannot reason about, and losing an entire resident session because
|
|
65
|
+
* someone hung up is itself the silent failure. Same policy the MCP probe already carries for an
|
|
66
|
+
* async EPIPE on a child's stdin (`scripts/probe-bridge-command.ts:88-97`).
|
|
67
|
+
*
|
|
68
|
+
* Anything that is NOT a peer hanging up is diagnosed exactly once with its code and message and
|
|
69
|
+
* then served on. Swallowing every error would hide the class this listener is not here to absorb;
|
|
70
|
+
* rethrowing from an event callback would be the crash this whole function exists to prevent.
|
|
71
|
+
*
|
|
72
|
+
* `diagnose` is injected so a gate can count diagnostics without capturing global stderr.
|
|
73
|
+
*/
|
|
74
|
+
export function attachAcceptedSocketDisconnectPolicy(socket, diagnose = (line) => console.error(line)) {
|
|
75
|
+
socket.on("error", (error) => {
|
|
76
|
+
if (isPeerDisconnect(error))
|
|
77
|
+
return;
|
|
78
|
+
diagnose(`[entwurf-control] control socket error (${error.code ?? "no code"}): ${error.message}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
41
81
|
export function parseGetInfoResponseData(data) {
|
|
42
82
|
const value = data;
|
|
43
83
|
return {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
|
|
28
28
|
*/
|
|
29
29
|
import { isOutOfSocketDomainGardenIdConflict, resolveFactList, UNOBSERVED_PEER, } from "./entwurf-facts.js";
|
|
30
|
-
import {
|
|
30
|
+
import { makeObservePeerFacts, readHerdrPlacementIndex } from "./entwurf-peer-observe.js";
|
|
31
31
|
import { isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
32
32
|
import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
|
|
33
33
|
import { scanSocketProbes } from "./socket-discovery.js";
|
|
@@ -140,7 +140,11 @@ export async function listEntwurfFacts(deps) {
|
|
|
140
140
|
// not performed becomes `unobserved`.
|
|
141
141
|
const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
|
|
142
142
|
const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
|
|
143
|
-
|
|
143
|
+
// The placement owner is read EXACTLY ONCE per listing, before any row is shaped, and
|
|
144
|
+
// never again for this call. That single read is the anti-watcher rule in code: herdr
|
|
145
|
+
// publishes no "the session reference landed" event, and a retry loop around that gap
|
|
146
|
+
// is the discovery watcher `docs/mux-launch-rail.md` §7 refuses by name.
|
|
147
|
+
const observe = deps.observe ?? makeObservePeerFacts((deps.readPlacementIndex ?? readHerdrPlacementIndex)());
|
|
144
148
|
let selectedObserve = observe;
|
|
145
149
|
if (deps.observationLimit !== undefined) {
|
|
146
150
|
if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
|
|
@@ -30,9 +30,14 @@
|
|
|
30
30
|
* the surrounding identity facts around it.
|
|
31
31
|
*/
|
|
32
32
|
import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
|
|
33
|
+
import { UNOBSERVED_PLACEMENT } from "./herdr-placement.js";
|
|
33
34
|
/** What a caller that measured nothing must say. Explicit and greppable — a row that
|
|
34
35
|
* silently defaulted to `none`/`absent` would be a fabricated fact. */
|
|
35
|
-
export const UNOBSERVED_PEER = {
|
|
36
|
+
export const UNOBSERVED_PEER = {
|
|
37
|
+
receiver: "unobserved",
|
|
38
|
+
transcript: "unobserved",
|
|
39
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
40
|
+
};
|
|
36
41
|
/**
|
|
37
42
|
* Compose a `PeerFact` from a citizen's identity and an optional socket probe.
|
|
38
43
|
*
|
|
@@ -57,6 +62,7 @@ export function resolvePeerFact(identity, socket, observations = UNOBSERVED_PEER
|
|
|
57
62
|
liveness: factLivenessOf(identity.backend, socket),
|
|
58
63
|
receiver: observations.receiver,
|
|
59
64
|
transcript: observations.transcript,
|
|
65
|
+
placement: observations.placement,
|
|
60
66
|
};
|
|
61
67
|
}
|
|
62
68
|
/**
|
|
@@ -13,14 +13,23 @@
|
|
|
13
13
|
* about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
|
|
14
14
|
* dispatch still computes routing at call time from the same underlying markers.
|
|
15
15
|
*
|
|
16
|
+
* THE PLACEMENT AXIS IS MEASURED ONCE, NOT PER CITIZEN (#116 S1). `receiver` and
|
|
17
|
+
* `transcript` are per-citizen filesystem questions; placement is one question asked of
|
|
18
|
+
* one placement owner about every pane it has. So the herdr read happens ABOVE this
|
|
19
|
+
* module (the provider does it once) and arrives here as an already-built index. That
|
|
20
|
+
* is also the anti-watcher shape: one read per listing, no retry, no wait for a pane
|
|
21
|
+
* whose session reference has not landed yet.
|
|
22
|
+
*
|
|
16
23
|
* ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
|
|
17
24
|
* `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
|
|
18
25
|
* The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
|
|
19
26
|
* wants a sentence — but they may not disagree, so the enum is a projection of that atom
|
|
20
27
|
* rather than a second opinion about the same markers.
|
|
21
28
|
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
22
30
|
import * as fs from "node:fs";
|
|
23
31
|
import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.js";
|
|
32
|
+
import { buildPlacementIndex, parseHerdrPaneList, resolvePlacement, } from "./herdr-placement.js";
|
|
24
33
|
import { metaCapabilityFor, metaReceiverMarkerPath, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
|
|
25
34
|
/**
|
|
26
35
|
* The mailbox receiver axis for one citizen.
|
|
@@ -60,7 +69,63 @@ function observeTranscript(identity) {
|
|
|
60
69
|
return "absent";
|
|
61
70
|
}
|
|
62
71
|
}
|
|
63
|
-
/**
|
|
72
|
+
/** How long the one placement read may take before we stop waiting for it. A listing
|
|
73
|
+
* must not hang on a placement owner: the column is a convenience and `unobserved` is
|
|
74
|
+
* a complete answer. */
|
|
75
|
+
const HERDR_READ_TIMEOUT_MS = 2000;
|
|
76
|
+
/**
|
|
77
|
+
* Read the placement owner ONCE, or decline to.
|
|
78
|
+
*
|
|
79
|
+
* TWO ENV FACTS DECIDE, AND BOTH ARE HERDR'S OWN (measured, herdr 0.9.0). `HERDR_ENV=1`
|
|
80
|
+
* is how herdr tells a process it is running inside herdr, and `HERDR_BIN_PATH` is the
|
|
81
|
+
* invocation path herdr's own plugin contract tells callers to use. Neither is
|
|
82
|
+
* discovered: if herdr did not put them in this process's environment we are not inside
|
|
83
|
+
* herdr and we make no claim. There is no path guess, no socket scan, and no PATH
|
|
84
|
+
* lookup — this must stay as explicit as Hard Rule 6 wants configuration to be.
|
|
85
|
+
*
|
|
86
|
+
* EVERY FAILURE IS `null`, NEVER AN EMPTY INDEX. A missing binary, a timeout, a nonzero
|
|
87
|
+
* exit or a payload we cannot parse all mean nobody measured. An empty index would say
|
|
88
|
+
* something much stronger — "herdr was read and has none of your citizens" — about a
|
|
89
|
+
* read that did not happen.
|
|
90
|
+
*/
|
|
91
|
+
export function readHerdrPlacementIndex(env = process.env) {
|
|
92
|
+
if (env.HERDR_ENV !== "1")
|
|
93
|
+
return null;
|
|
94
|
+
const bin = env.HERDR_BIN_PATH;
|
|
95
|
+
if (typeof bin !== "string" || bin.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
let stdout;
|
|
98
|
+
try {
|
|
99
|
+
stdout = execFileSync(bin, ["pane", "list"], {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
timeout: HERDR_READ_TIMEOUT_MS,
|
|
102
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Bounded environment probe: a placement owner that did not answer is a citizen
|
|
107
|
+
// column that reads `unobserved`, not a listing that fails.
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const rows = parseHerdrPaneList(stdout);
|
|
111
|
+
return rows === null ? null : buildPlacementIndex(rows);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build the production observer over an already-resolved placement index.
|
|
115
|
+
*
|
|
116
|
+
* `null` means no placement owner was read on this host — every citizen then reads
|
|
117
|
+
* `unobserved`, which is the only honest answer when nobody looked. It is NOT `none`:
|
|
118
|
+
* that word is reserved for a herdr that WAS read and does not have this citizen.
|
|
119
|
+
*/
|
|
120
|
+
export function makeObservePeerFacts(placementIndex) {
|
|
121
|
+
return (identity) => ({
|
|
122
|
+
receiver: observeReceiver(identity),
|
|
123
|
+
transcript: observeTranscript(identity),
|
|
124
|
+
placement: resolvePlacement(placementIndex, identity),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** The production observer with no placement owner read — the shape every caller that
|
|
128
|
+
* has not resolved an index gets, and the default on a host with no herdr. */
|
|
64
129
|
export function observePeerFacts(identity) {
|
|
65
|
-
return
|
|
130
|
+
return makeObservePeerFacts(null)(identity);
|
|
66
131
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* socketPath rows — the pre-record socket-scan worldview) is gone with the
|
|
23
23
|
* `controlDir` it exposed. Socket paths are dispatch-internal transport.
|
|
24
24
|
*/
|
|
25
|
+
import { renderPlacement } from "./herdr-placement.js";
|
|
25
26
|
/** Human rows and expensive provider observations share this one presentation budget. */
|
|
26
27
|
export const ENTWURF_PEERS_RENDER_LIMIT = 32;
|
|
27
28
|
// `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
|
|
@@ -32,10 +33,17 @@ export const ENTWURF_PEERS_RENDER_LIMIT = 32;
|
|
|
32
33
|
// be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
|
|
33
34
|
// same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
|
|
34
35
|
// existence only, never the path.
|
|
36
|
+
//
|
|
37
|
+
// `placement` (#116 S1) is the third observed fact and the one most likely to be
|
|
38
|
+
// MISREAD, so it is placed at the END of the row — away from `liveness` — and printed
|
|
39
|
+
// as `herdr <pane>` rather than a bare pane id. A pane says where a citizen was last
|
|
40
|
+
// SEEN by a placement owner; it says nothing about whether that citizen is running, and
|
|
41
|
+
// nothing about how to reach it. `unobserved` = nobody looked (no herdr here), `none` =
|
|
42
|
+
// herdr was read and does not have this one, `ambiguous` = more than one pane claimed it.
|
|
35
43
|
function renderPeerLine(p) {
|
|
36
44
|
const model = p.model ?? "(unknown)";
|
|
37
45
|
return (`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
|
|
38
|
-
`transcript=${p.transcript} cwd=${p.cwd} model=${model}`);
|
|
46
|
+
`transcript=${p.transcript} cwd=${p.cwd} model=${model} placement=${renderPlacement(p.placement)}`);
|
|
39
47
|
}
|
|
40
48
|
function renderDiagnosticLine(d) {
|
|
41
49
|
switch (d.kind) {
|
|
@@ -125,3 +125,27 @@ export function computeSelfAddressability(facts) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The mirror of `computeSelfAddressability`, as a decision rather than a claim.
|
|
130
|
+
*
|
|
131
|
+
* That function exists so a surface never CLAIMS an addressability it lacks. This one exists so a
|
|
132
|
+
* surface does not stay SILENT about lacking one: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr plugin
|
|
133
|
+
* install wires this extension at user scope, so it loads in every pi on the host while citizenship
|
|
134
|
+
* stays argv-gated on purpose — and a plain `pi` after a green install is therefore silent,
|
|
135
|
+
* tool-less and indistinguishable from an install that did nothing.
|
|
136
|
+
*
|
|
137
|
+
* WHY THIS IS A FUNCTION AND NOT THREE `if`s AT THE CALL SITE. It was three `if`s, and its gate was
|
|
138
|
+
* a source regex asserting the call existed. `[측정 2026-09-17, 독립 검수 claude-opus-5 + 재현]` two
|
|
139
|
+
* mutants walked straight through that green: moving the call into the CITIZEN branch (so citizens
|
|
140
|
+
* are told they are not citizens and plain pi stays silent), and deleting the once-latch (so the
|
|
141
|
+
* line repeats on every session switch). A regex can see that a call exists; it cannot see which
|
|
142
|
+
* branch it is in or how often it fires. Those are properties of FACTS, so the facts are now
|
|
143
|
+
* arguments and the answer is a value a truth table can exhaust.
|
|
144
|
+
*/
|
|
145
|
+
export function decideUncitizenedNotice(facts) {
|
|
146
|
+
if (facts.controlEnabled)
|
|
147
|
+
return false;
|
|
148
|
+
if (!facts.hasUI)
|
|
149
|
+
return false;
|
|
150
|
+
return !facts.alreadyShown;
|
|
151
|
+
}
|
|
@@ -102,7 +102,7 @@ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"];
|
|
|
102
102
|
// unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
|
|
103
103
|
// THIS set is a deliberate future decision, gated by a real compatible
|
|
104
104
|
// control-socket predicate — never by silently mapping sessions to dead/indeterminate
|
|
105
|
-
// (R1 핵심).
|
|
105
|
+
// (R1 핵심). pi-extensions/lib/entwurf-facts.test.ts pins this == ["pi"] and asserts the native-push
|
|
106
106
|
// domain is disjoint from it.
|
|
107
107
|
export const LIVENESS_DOMAIN_BACKENDS = ["pi"];
|
|
108
108
|
export function isLivenessSupported(backend) {
|
|
@@ -113,7 +113,7 @@ export function isLivenessSupported(backend) {
|
|
|
113
113
|
// live vendor app-server target probe), NOT a control socket. This domain is DISJOINT
|
|
114
114
|
// from LIVENESS_DOMAIN_BACKENDS: an Antigravity or Codex session is `unsupported` on
|
|
115
115
|
// the socket FACT axis (entwurf_peers) yet independently measured on the native-push
|
|
116
|
-
// axis. The two are separate rails on purpose —
|
|
116
|
+
// axis. The two are separate rails on purpose — pi-extensions/lib/entwurf-facts.test.ts pins both sets
|
|
117
117
|
// and asserts their intersection is ∅ (a backend can never be in both domains).
|
|
118
118
|
export const NATIVE_PUSH_BACKENDS = ["antigravity", "codex"];
|
|
119
119
|
export function nativePushSupported(backend) {
|
|
@@ -79,7 +79,7 @@ export const ENTWURF_V2_LOCK_DIR = path.join(os.homedir(), ".pi", "entwurf-v2-lo
|
|
|
79
79
|
export const LOCK_SUFFIX = ".lock";
|
|
80
80
|
export const LOCK_OWNER = "entwurf_v2";
|
|
81
81
|
/** The reject reason a lock conflict maps to. Kept as a literal here (the lock
|
|
82
|
-
* primitive stays decoupled from the full contract); `
|
|
82
|
+
* primitive stays decoupled from the full contract); `pi-extensions/lib/entwurf-v2-lock.test.ts`
|
|
83
83
|
* cross-checks it against the contract's ENTWURF_V2_REJECT_REASONS so the two
|
|
84
84
|
* cannot drift. */
|
|
85
85
|
export const LOCK_CONFLICT_REASON = "target-locked";
|