@junghanacs/entwurf 0.22.0 → 0.23.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 +1 -0
- package/CHANGELOG.md +380 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +6 -0
- 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/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/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 +4 -4
- package/pi-extensions/entwurf-control.ts +91 -52
- 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/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 +396 -8
- 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-facts.ts +12 -6
- 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 +16 -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-release-gate-outcomes.ts +12 -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/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/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- 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-codex-birth.sh +281 -12
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
|
@@ -27,6 +27,17 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync
|
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
28
|
import * as path from "node:path";
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
|
+
import {
|
|
31
|
+
appendSessionStartGroup,
|
|
32
|
+
canonicalJson,
|
|
33
|
+
certifySplice,
|
|
34
|
+
declarationDigest,
|
|
35
|
+
entwurfDeclarationGroup,
|
|
36
|
+
removeEntwurfDescription,
|
|
37
|
+
removeSessionStartGroup,
|
|
38
|
+
selectEntwurfDeclaration,
|
|
39
|
+
trustReceiptKey,
|
|
40
|
+
} from "../pi-extensions/lib/codex-declaration.js";
|
|
30
41
|
import { listAllMetaIdentitiesDir, type MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
31
42
|
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
32
43
|
|
|
@@ -123,6 +134,182 @@ ok(
|
|
|
123
134
|
);
|
|
124
135
|
ok("the handler timeout is 30", handler.timeout === 30);
|
|
125
136
|
|
|
137
|
+
// ── 2. the declaration leaf: what entwurf owns inside a file it SHARES (#117) ─
|
|
138
|
+
// The installer, the inverse, the doctor and the fresh preflight all decide with this leaf, so
|
|
139
|
+
// its two mechanisms are proven here once rather than four times through their shells. The
|
|
140
|
+
// mechanisms are: a NORMALIZED digest (blind to whatever a neighbour's serializer does) and a
|
|
141
|
+
// SPAN SPLICE (so a neighbour's bytes are copied through rather than rewritten).
|
|
142
|
+
{
|
|
143
|
+
const fakeLauncher = "/opt/entwurf/codex-birth-launch.sh";
|
|
144
|
+
const ourGroup = entwurfDeclarationGroup(fakeLauncher);
|
|
145
|
+
const herdrGroup = {
|
|
146
|
+
hooks: [{ type: "command", command: "bash '/home/op/.codex/herdr-agent-state.sh' session", timeout: 10 }],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
ok(
|
|
150
|
+
"[QK:CODEX-DECL-CANONICAL-KEY-ORDER] the canonical form sorts keys recursively, so two documents that differ only in key order digest identically",
|
|
151
|
+
canonicalJson({ b: 1, a: [{ y: 2, x: 3 }] }) === canonicalJson({ a: [{ x: 3, y: 2 }], b: 1 }),
|
|
152
|
+
);
|
|
153
|
+
ok(
|
|
154
|
+
"[QK:CODEX-DECL-DIGEST-BINDS-LAUNCHER] the digest is sensitive to the launcher path — the one string the vendor keys its trust receipt to",
|
|
155
|
+
declarationDigest(ourGroup) !== declarationDigest(entwurfDeclarationGroup(`${fakeLauncher}.bak`)),
|
|
156
|
+
);
|
|
157
|
+
ok(
|
|
158
|
+
"[QK:CODEX-DECL-DIGEST-BINDS-EVENT] the digest covers the EVENT as well as the group, so the same group under another event is another identity",
|
|
159
|
+
declarationDigest(ourGroup) !== declarationDigest(ourGroup, "SubagentStart"),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// SELECTION is by the launcher command, at whatever index that command happens to sit.
|
|
163
|
+
for (const [label, groups, wantIndex] of [
|
|
164
|
+
["alone", [ourGroup], 0],
|
|
165
|
+
["after a neighbour", [herdrGroup, ourGroup], 1],
|
|
166
|
+
["before a neighbour", [ourGroup, herdrGroup], 0],
|
|
167
|
+
["between neighbours", [herdrGroup, ourGroup, herdrGroup], 1],
|
|
168
|
+
] as const) {
|
|
169
|
+
const picked = selectEntwurfDeclaration({ hooks: { SessionStart: groups } }, fakeLauncher);
|
|
170
|
+
ok(
|
|
171
|
+
`[QK:CODEX-DECL-SELECT-BY-COMMAND] entwurf's declaration is selected by its command, ${label} (index ${wantIndex})`,
|
|
172
|
+
picked.ok && picked.groupIndex === wantIndex && picked.handlerIndex === 0,
|
|
173
|
+
);
|
|
174
|
+
ok(
|
|
175
|
+
`the trust key names that measured position, ${label}`,
|
|
176
|
+
picked.ok &&
|
|
177
|
+
trustReceiptKey("/h/hooks.json", picked.groupIndex, picked.handlerIndex) ===
|
|
178
|
+
`/h/hooks.json:session_start:${wantIndex}:0`,
|
|
179
|
+
);
|
|
180
|
+
ok(
|
|
181
|
+
`every other group is reported FOREIGN and none of ours is, ${label}`,
|
|
182
|
+
picked.ok && picked.foreign.length === groups.length - 1 && !picked.foreign.some((g) => g.index === wantIndex),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// THE HANDLER KEY SET — and this claim is a DIAGNOSIS contract, not an acceptance one. Say it
|
|
187
|
+
// plainly, because the QK token predates the narrowing and an unchanged name over a changed
|
|
188
|
+
// meaning is how a mutant stops testing what its claim says (sol, 2026-09-18). ACCEPTANCE is
|
|
189
|
+
// owned by the normalized digest: a handler carrying `async` digests differently, so the
|
|
190
|
+
// preflight refuses it whether or not this predicate exists, and the mutant below cannot make
|
|
191
|
+
// a drifted declaration green. What ONLY this predicate buys is the NAME — "the extra key is
|
|
192
|
+
// `async`, and every extra key changes the trust identity" — instead of an anonymous digest
|
|
193
|
+
// mismatch, and an operator repairs those two with different hands. `async: true` is the case
|
|
194
|
+
// that earns the name: it would let the turn proceed before the record exists.
|
|
195
|
+
{
|
|
196
|
+
const extra = selectEntwurfDeclaration(
|
|
197
|
+
{ hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } },
|
|
198
|
+
fakeLauncher,
|
|
199
|
+
);
|
|
200
|
+
ok(
|
|
201
|
+
"[QK:FRESHCALL-CODEX-HOOK-KEYS] a handler carrying `async` — or any key the installer never writes — is NAMED as a shape drift that quotes the extra key; acceptance is the digest's job either way, and what this claim owns is the diagnosis an operator reads",
|
|
202
|
+
!extra.ok &&
|
|
203
|
+
extra.code === "declaration-shape-drifted" &&
|
|
204
|
+
extra.detail.includes("exactly type+command+timeout") &&
|
|
205
|
+
extra.detail.includes("async"),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [code, document] of [
|
|
210
|
+
["declaration-absent", { hooks: { SessionStart: [herdrGroup] } }],
|
|
211
|
+
["declaration-duplicated", { hooks: { SessionStart: [ourGroup, herdrGroup, ourGroup] } }],
|
|
212
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ matcher: "startup", ...ourGroup }] } }],
|
|
213
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [{ ...ourGroup.hooks[0], async: true }] }] } }],
|
|
214
|
+
["declaration-shape-drifted", { hooks: { SessionStart: [{ hooks: [ourGroup.hooks[0], herdrGroup.hooks[0]] }] } }],
|
|
215
|
+
["hooks-unreadable", { hooks: { SessionStart: "not an array" } }],
|
|
216
|
+
] as const) {
|
|
217
|
+
const picked = selectEntwurfDeclaration(document, fakeLauncher);
|
|
218
|
+
ok(
|
|
219
|
+
`[QK:CODEX-DECL-NAMED-REFUSALS] ${code} is returned by name rather than as a silent miss (${JSON.stringify(document).slice(0, 60)}…)`,
|
|
220
|
+
!picked.ok && picked.code === code,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// THE SPLICE. What is asserted is not "the result parses" but "every byte a neighbour owns is
|
|
225
|
+
// literally still there", because a re-serialize would also parse. So the fixtures below are
|
|
226
|
+
// deliberately formatted the way NOTHING in this repo serializes — tabs, inline groups, the
|
|
227
|
+
// neighbour's keys in the order serde emits them — and the assertion is a literal substring.
|
|
228
|
+
// A fixture written with `JSON.stringify(_, null, 2)` would let a whole-document rewrite pass
|
|
229
|
+
// unnoticed, which is the one failure this mechanism exists to prevent.
|
|
230
|
+
const herdrLine = `\t\t\t{"hooks": [{"command": ${JSON.stringify(herdrGroup.hooks[0].command)}, "timeout": 10, "type": "command"}]}`;
|
|
231
|
+
const ourLine = `\t\t\t{"hooks": [{"type": "command", "command": "'${fakeLauncher}'", "timeout": 30}]}`;
|
|
232
|
+
const shared = [
|
|
233
|
+
"{",
|
|
234
|
+
'\t"description": "entwurf codex-birth 9.9.9 — prose we authored",',
|
|
235
|
+
'\t"hooks": {',
|
|
236
|
+
'\t\t"SessionStart": [',
|
|
237
|
+
`${ourLine},`,
|
|
238
|
+
herdrLine,
|
|
239
|
+
"\t\t]",
|
|
240
|
+
"\t}",
|
|
241
|
+
"}",
|
|
242
|
+
].join("\n");
|
|
243
|
+
ok(
|
|
244
|
+
"the shared fixture really does hold the neighbour's bytes verbatim, in formatting nothing here would reproduce",
|
|
245
|
+
shared.includes(herdrLine) && JSON.stringify(JSON.parse(shared), null, 2) !== shared,
|
|
246
|
+
);
|
|
247
|
+
const withoutOurs = removeEntwurfDescription(
|
|
248
|
+
certifySplice(removeSessionStartGroup(shared, 0), { ...JSON.parse(shared), hooks: { SessionStart: [herdrGroup] } }),
|
|
249
|
+
);
|
|
250
|
+
ok(
|
|
251
|
+
"[QK:CODEX-DECL-SPLICE-KEEPS-FOREIGN-BYTES] removing entwurf's group leaves the neighbour's bytes literally untouched",
|
|
252
|
+
withoutOurs.includes(herdrLine),
|
|
253
|
+
);
|
|
254
|
+
ok(
|
|
255
|
+
"removing entwurf's group also removes entwurf's own description and nothing else",
|
|
256
|
+
canonicalJson(JSON.parse(withoutOurs)) === canonicalJson({ hooks: { SessionStart: [herdrGroup] } }),
|
|
257
|
+
);
|
|
258
|
+
const foreignOnly = ["{", '\t"hooks": {', '\t\t"SessionStart": [', herdrLine, "\t\t]", "\t}", "}"].join("\n");
|
|
259
|
+
const rejoined = certifySplice(appendSessionStartGroup(foreignOnly, ourGroup), {
|
|
260
|
+
hooks: { SessionStart: [herdrGroup, ourGroup] },
|
|
261
|
+
});
|
|
262
|
+
ok(
|
|
263
|
+
"[QK:CODEX-DECL-SPLICE-APPENDS-LAST] appending entwurf's group leaves the neighbour at its own index — and therefore at its own trust receipt — with its bytes unchanged",
|
|
264
|
+
selectEntwurfDeclaration(JSON.parse(rejoined), fakeLauncher).groupIndex === 1 && rejoined.includes(herdrLine),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// THE POST-CONDITION IS THE SAFETY. The span reader is the only new way this unit can damage
|
|
268
|
+
// a file nobody asked it to touch, so no splice is ever trusted on the reader's word.
|
|
269
|
+
let refused = "";
|
|
270
|
+
try {
|
|
271
|
+
certifySplice(rejoined, { hooks: { SessionStart: [herdrGroup] } });
|
|
272
|
+
} catch (err) {
|
|
273
|
+
refused = err instanceof Error ? err.message : String(err);
|
|
274
|
+
}
|
|
275
|
+
ok(
|
|
276
|
+
"[QK:CODEX-DECL-SPLICE-CERTIFIED] a splice whose result is not the value the caller intended is REFUSED, never returned",
|
|
277
|
+
refused.includes("not the value this edit intended"),
|
|
278
|
+
);
|
|
279
|
+
refused = "";
|
|
280
|
+
try {
|
|
281
|
+
certifySplice("{not json", { hooks: {} });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
refused = err instanceof Error ? err.message : String(err);
|
|
284
|
+
}
|
|
285
|
+
ok("a splice that does not parse is refused with its own reason", refused.includes("does not parse"));
|
|
286
|
+
|
|
287
|
+
// The span reader must find the SAME structure `JSON.parse` does, across the shapes a hooks
|
|
288
|
+
// file is actually written in. A disagreement here is the reader silently editing the wrong
|
|
289
|
+
// range, which is exactly what the post-condition above is guarding.
|
|
290
|
+
for (const [label, text] of [
|
|
291
|
+
["compact", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } })],
|
|
292
|
+
["2-space", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, 2)],
|
|
293
|
+
["tab", JSON.stringify({ hooks: { SessionStart: [ourGroup, herdrGroup] } }, null, "\t")],
|
|
294
|
+
[
|
|
295
|
+
"strings that contain braces and escaped quotes",
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
description: 'a } b ] c \\" d',
|
|
298
|
+
hooks: { SessionStart: [ourGroup, { hooks: [{ type: "command", command: '] } "x"', timeout: 1 }] }] },
|
|
299
|
+
}),
|
|
300
|
+
],
|
|
301
|
+
] as const) {
|
|
302
|
+
const trimmed = certifySplice(removeSessionStartGroup(text, 0), {
|
|
303
|
+
...(JSON.parse(text) as Record<string, unknown>),
|
|
304
|
+
hooks: { SessionStart: [(JSON.parse(text) as { hooks: { SessionStart: unknown[] } }).hooks.SessionStart[1]] },
|
|
305
|
+
});
|
|
306
|
+
ok(
|
|
307
|
+
`[QK:CODEX-DECL-SPAN-READER-EXACT] the span reader agrees with JSON.parse on ${label} formatting`,
|
|
308
|
+
JSON.parse(trimmed) !== null,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
126
313
|
// ── 3. FIRE the installed launcher, the way codex fires it ──────────────────
|
|
127
314
|
const MEASURED = {
|
|
128
315
|
session_id: "01a08147-fbad-78d2-b266-e058f322125e",
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* `sendRpcCommand` reject `connection closed before response` (the 2026-05-18
|
|
17
17
|
* receiver-stuck backstop the settled-guard preserves).
|
|
18
18
|
* 5. get_info runtime helper parses/formats cwd/model/idle once for every caller.
|
|
19
|
+
* 7. accepted-connection disconnect policy — a REAL peer that hangs up mid-exchange must not
|
|
20
|
+
* reach this process as an uncaught exception (the 2026-09-14 C4 incident: a resident pi died
|
|
21
|
+
* writing a late response to a sender that had already timed out), must not be diagnosed, and
|
|
22
|
+
* an error that is NOT a disconnect must be diagnosed exactly once without rethrowing.
|
|
23
|
+
* 8. ordering — the surface installs that policy before setEncoding and before the data handler.
|
|
19
24
|
*
|
|
20
25
|
* No model / auth / pi process — only `net.Server` on a tmp socket, so it rides `pnpm run check:full`.
|
|
21
26
|
*/
|
|
@@ -27,6 +32,7 @@ import * as os from "node:os";
|
|
|
27
32
|
import * as path from "node:path";
|
|
28
33
|
import { fileURLToPath } from "node:url";
|
|
29
34
|
import {
|
|
35
|
+
attachAcceptedSocketDisconnectPolicy,
|
|
30
36
|
fetchControlSocketRuntimeInfo,
|
|
31
37
|
formatRuntimeModel,
|
|
32
38
|
formatSenderInfoBlock,
|
|
@@ -217,6 +223,138 @@ async function main(): Promise<void> {
|
|
|
217
223
|
ok("6: undefined origin/replyable render nothing", !formatSenderInfoBlock(base).includes("origin"));
|
|
218
224
|
}
|
|
219
225
|
|
|
226
|
+
// ── 7. accepted-connection disconnect policy (the server half of the wire) ──
|
|
227
|
+
// `[측정 2026-09-14]` a resident pi was killed by a late response written to a socket whose
|
|
228
|
+
// peer had already timed out and hung up. The EPIPE arrived asynchronously as an `error`
|
|
229
|
+
// event, and an `error` event with no listener is an uncaught exception. These cells drive a
|
|
230
|
+
// REAL unix socket to a real peer disconnect — no fake control server, no synthetic stand-in
|
|
231
|
+
// for the crash itself.
|
|
232
|
+
{
|
|
233
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rpc-disconnect-"));
|
|
234
|
+
const socketPath = path.join(dir, "s.sock");
|
|
235
|
+
const diagnostics: string[] = [];
|
|
236
|
+
const accepted: net.Socket[] = [];
|
|
237
|
+
const server = net.createServer((socket) => {
|
|
238
|
+
// Exactly what the surface does, and the ONLY error listener on this connection: if the
|
|
239
|
+
// policy stops absorbing, nothing else here is catching it.
|
|
240
|
+
attachAcceptedSocketDisconnectPolicy(socket, (line) => diagnostics.push(line));
|
|
241
|
+
accepted.push(socket);
|
|
242
|
+
});
|
|
243
|
+
await new Promise<void>((resolve) => server.listen(socketPath, resolve));
|
|
244
|
+
|
|
245
|
+
// Record instead of dying, so the crash becomes an assertion we can attribute rather than
|
|
246
|
+
// a stack trace that kills the gate run itself.
|
|
247
|
+
const uncaught: Error[] = [];
|
|
248
|
+
const onUncaught = (err: Error) => uncaught.push(err);
|
|
249
|
+
process.on("uncaughtException", onUncaught);
|
|
250
|
+
// Every socket this cell opens, torn down in `finally` — `server.close()` only calls back
|
|
251
|
+
// once the LAST connection is gone, so a client left open on a failing assertion would turn
|
|
252
|
+
// a red gate into a hang (which is not a kill: a mutant must fail bounded, with its QK).
|
|
253
|
+
const opened: net.Socket[] = [];
|
|
254
|
+
try {
|
|
255
|
+
const client = net.createConnection(socketPath);
|
|
256
|
+
opened.push(client);
|
|
257
|
+
await new Promise<void>((resolve, reject) => {
|
|
258
|
+
client.once("connect", resolve);
|
|
259
|
+
client.once("error", reject);
|
|
260
|
+
});
|
|
261
|
+
// The sender gives up and hangs up — the C4 shape exactly.
|
|
262
|
+
client.on("error", () => {});
|
|
263
|
+
client.destroy();
|
|
264
|
+
|
|
265
|
+
// The receiver answers LATE, into a peer that is gone. Bounded: writes until the stream
|
|
266
|
+
// records an error, never longer.
|
|
267
|
+
for (let i = 0; i < 200 && accepted[0] !== undefined && accepted[0].errored === null; i++) {
|
|
268
|
+
try {
|
|
269
|
+
accepted[0].write(`${"x".repeat(64 * 1024)}\n`);
|
|
270
|
+
} catch {
|
|
271
|
+
// A synchronous ERR_STREAM_DESTROYED is the OTHER half — writeResponse's own
|
|
272
|
+
// try/catch owns it. This loop is hunting the asynchronous one.
|
|
273
|
+
}
|
|
274
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Fixture integrity FIRST: if no peer-disconnect error ever landed, the two cells below
|
|
278
|
+
// would be vacuously green no matter what the policy did.
|
|
279
|
+
const errored = accepted[0]?.errored as NodeJS.ErrnoException | null | undefined;
|
|
280
|
+
ok(
|
|
281
|
+
"7: fixture — a real peer disconnect produced an async EPIPE/ECONNRESET on the accepted socket",
|
|
282
|
+
errored != null && (errored.code === "EPIPE" || errored.code === "ECONNRESET"),
|
|
283
|
+
);
|
|
284
|
+
ok(
|
|
285
|
+
"7: [QK:CONTROL-SOCKET-NO-UNCAUGHT] a vanished peer never reaches the process as an uncaught exception " +
|
|
286
|
+
`(would have killed this resident session) — uncaught=${JSON.stringify(uncaught.map((e) => String(e)))}`,
|
|
287
|
+
uncaught.length === 0,
|
|
288
|
+
);
|
|
289
|
+
ok(
|
|
290
|
+
"7: [QK:CONTROL-SOCKET-ABSORBS-PEER-DISCONNECT] a vanished peer is absorbed silently, never diagnosed " +
|
|
291
|
+
`(it describes the client, not our state) — diagnostics=${JSON.stringify(diagnostics)}`,
|
|
292
|
+
diagnostics.length === 0,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Unexpected code on a REAL accepted socket: diagnosed exactly once, with code AND
|
|
296
|
+
// message, and the emit must not throw back out of the event callback.
|
|
297
|
+
const client2 = net.createConnection(socketPath);
|
|
298
|
+
opened.push(client2);
|
|
299
|
+
await new Promise<void>((resolve, reject) => {
|
|
300
|
+
client2.once("connect", resolve);
|
|
301
|
+
client2.once("error", reject);
|
|
302
|
+
});
|
|
303
|
+
client2.on("error", () => {});
|
|
304
|
+
// The SECOND accepted connection — live, never disconnected, so the diagnosis path is
|
|
305
|
+
// exercised on a socket in ordinary service rather than on the already-errored one.
|
|
306
|
+
for (let i = 0; i < 200 && accepted[1] === undefined; i++) {
|
|
307
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
308
|
+
}
|
|
309
|
+
const live = accepted[1];
|
|
310
|
+
ok("7: fixture — the second connection was accepted and carries the policy", live !== undefined);
|
|
311
|
+
const unexpected: NodeJS.ErrnoException = Object.assign(new Error("no space left on device"), {
|
|
312
|
+
code: "ENOSPC",
|
|
313
|
+
});
|
|
314
|
+
const before = diagnostics.length;
|
|
315
|
+
let threw = false;
|
|
316
|
+
try {
|
|
317
|
+
live?.emit("error", unexpected);
|
|
318
|
+
} catch {
|
|
319
|
+
threw = true;
|
|
320
|
+
}
|
|
321
|
+
const emitted = diagnostics.slice(before);
|
|
322
|
+
ok(
|
|
323
|
+
"7: [QK:CONTROL-SOCKET-DIAGNOSES-UNEXPECTED] a non-disconnect error diagnoses EXACTLY once with code and " +
|
|
324
|
+
`message, and never rethrows from the event callback — threw=${threw} emitted=${JSON.stringify(emitted)}`,
|
|
325
|
+
threw === false &&
|
|
326
|
+
emitted.length === 1 &&
|
|
327
|
+
emitted[0]!.includes("ENOSPC") &&
|
|
328
|
+
emitted[0]!.includes("no space left on device"),
|
|
329
|
+
);
|
|
330
|
+
} finally {
|
|
331
|
+
process.off("uncaughtException", onUncaught);
|
|
332
|
+
for (const socket of [...opened, ...accepted]) socket.destroy();
|
|
333
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
334
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── 8. the surface installs the policy FIRST on every accepted connection ───
|
|
339
|
+
// Ordering is the contract, not merely presence: a listener attached after setEncoding or
|
|
340
|
+
// after the data handler leaves a window in which the very first write can still kill us.
|
|
341
|
+
{
|
|
342
|
+
const src = await fs.readFile(CONTROL_SRC, "utf8");
|
|
343
|
+
const accept = src.slice(src.indexOf("const server = net.createServer((socket) => {"));
|
|
344
|
+
const attachAt = accept.indexOf("attachAcceptedSocketDisconnectPolicy(socket)");
|
|
345
|
+
const encodingAt = accept.indexOf('socket.setEncoding("utf8")');
|
|
346
|
+
const dataAt = accept.indexOf('socket.on("data"');
|
|
347
|
+
ok(
|
|
348
|
+
"8: [QK:CONTROL-SOCKET-POLICY-FIRST] createServer attaches the disconnect policy before setEncoding and " +
|
|
349
|
+
`before the data handler — attach=${attachAt} setEncoding=${encodingAt} data=${dataAt}`,
|
|
350
|
+
attachAt !== -1 && encodingAt !== -1 && dataAt !== -1 && attachAt < encodingAt && attachAt < dataAt,
|
|
351
|
+
);
|
|
352
|
+
ok(
|
|
353
|
+
"8: the surface consumes the shared policy and defines no second error listener of its own",
|
|
354
|
+
/from "\.\/lib\/entwurf-control-rpc\.js"/.test(src) && !/socket\.on\("error"/.test(src),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
220
358
|
console.log(`\ncheck-entwurf-control-rpc: ${passed} checks passed`);
|
|
221
359
|
}
|
|
222
360
|
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import assert from "node:assert/strict";
|
|
31
31
|
import * as path from "node:path";
|
|
32
32
|
import { type EntwurfFactsDeps, listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
|
|
33
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
33
34
|
import { type MetaCitizenBackend, serializeMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
34
35
|
import { SOCKET_SUFFIX, type SocketDirEntry } from "../pi-extensions/lib/socket-discovery.ts";
|
|
35
36
|
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
@@ -83,6 +84,7 @@ function deps(
|
|
|
83
84
|
observe: (identity) => ({
|
|
84
85
|
receiver: identity.backend === "claude-code" ? "inactive" : "n/a",
|
|
85
86
|
transcript: identity.gardenId === GID_CLAUDE ? "absent" : "exists",
|
|
87
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
86
88
|
}),
|
|
87
89
|
// Kind-carrying entries, like the real bindings: the listing must be able to refuse a
|
|
88
90
|
// symlinked record without following it. `irregularMeta` names the ones that are not
|
|
@@ -186,7 +188,7 @@ async function main(): Promise<void> {
|
|
|
186
188
|
const all = deps(meta, {});
|
|
187
189
|
all.observe = (identity) => {
|
|
188
190
|
allObserved.push(identity.gardenId);
|
|
189
|
-
return { receiver: "active", transcript: "exists" };
|
|
191
|
+
return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
|
|
190
192
|
};
|
|
191
193
|
await listEntwurfFacts(all);
|
|
192
194
|
ok("Q112: generic provider without a presentation limit observes all 40 citizens", allObserved.length === 40);
|
|
@@ -196,7 +198,7 @@ async function main(): Promise<void> {
|
|
|
196
198
|
d.observationLimit = 32;
|
|
197
199
|
d.observe = (identity) => {
|
|
198
200
|
observed.push(identity.gardenId);
|
|
199
|
-
return { receiver: "active", transcript: "exists" };
|
|
201
|
+
return { receiver: "active", transcript: "exists", placement: UNOBSERVED_PLACEMENT };
|
|
200
202
|
};
|
|
201
203
|
const r = await listEntwurfFacts(d);
|
|
202
204
|
ok("Q112: full machine payload retains all 40 certified citizens", r.facts.peers.length === 40);
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
isLivenessSupported,
|
|
37
37
|
LIVENESS_DOMAIN_BACKENDS,
|
|
38
38
|
} from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
39
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
39
40
|
import {
|
|
40
41
|
META_CITIZEN_BACKENDS,
|
|
41
42
|
type MetaCitizenBackend,
|
|
@@ -138,7 +139,7 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
138
139
|
ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
// ── facts-only keyset: identity facts + liveness + the
|
|
142
|
+
// ── facts-only keyset: identity facts + liveness + the three observed axes, and
|
|
142
143
|
// still NO verb-routing / no transcript PATH ────────────────────────────────
|
|
143
144
|
{
|
|
144
145
|
const fact = resolvePeerFact(identity("pi"), "alive");
|
|
@@ -154,20 +155,25 @@ for (const backend of META_CITIZEN_BACKENDS) {
|
|
|
154
155
|
"recordUpdatedAt",
|
|
155
156
|
"receiver",
|
|
156
157
|
"transcript",
|
|
158
|
+
"placement",
|
|
157
159
|
].sort();
|
|
158
160
|
assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
|
|
159
|
-
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript)", true);
|
|
161
|
+
ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript/placement)", true);
|
|
160
162
|
|
|
161
163
|
// #101: an unmeasured row says so. `unobserved` is the default for a caller that
|
|
162
164
|
// injected no observer — never `none`/`absent`, which would be a fabricated fact.
|
|
163
165
|
ok(
|
|
164
|
-
"an observer-less composition reports
|
|
165
|
-
fact.receiver === "unobserved" && fact.transcript === "unobserved",
|
|
166
|
+
"an observer-less composition reports all three axes as unobserved (never a default fact)",
|
|
167
|
+
fact.receiver === "unobserved" && fact.transcript === "unobserved" && fact.placement.kind === "unobserved",
|
|
166
168
|
);
|
|
167
|
-
const observed = resolvePeerFact(identity("claude-code"), null, {
|
|
169
|
+
const observed = resolvePeerFact(identity("claude-code"), null, {
|
|
170
|
+
receiver: "inactive",
|
|
171
|
+
transcript: "absent",
|
|
172
|
+
placement: UNOBSERVED_PLACEMENT,
|
|
173
|
+
});
|
|
168
174
|
ok(
|
|
169
175
|
"an injected observation rides onto the fact verbatim",
|
|
170
|
-
observed.receiver === "inactive" && observed.transcript === "absent",
|
|
176
|
+
observed.receiver === "inactive" && observed.transcript === "absent" && observed.placement.kind === "unobserved",
|
|
171
177
|
);
|
|
172
178
|
|
|
173
179
|
const FORBIDDEN = [
|
|
@@ -33,6 +33,7 @@ import { type EntwurfFactsResult, recordLessSocketMessage } from "../pi-extensio
|
|
|
33
33
|
import type { PeerFact } from "../pi-extensions/lib/entwurf-facts.ts";
|
|
34
34
|
import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
|
|
35
35
|
import type { FactLiveness } from "../pi-extensions/lib/entwurf-v2-contract.ts";
|
|
36
|
+
import { UNOBSERVED_PLACEMENT } from "../pi-extensions/lib/herdr-placement.ts";
|
|
36
37
|
import type { MetaCitizenBackend } from "../pi-extensions/lib/meta-session.ts";
|
|
37
38
|
import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
|
|
38
39
|
|
|
@@ -51,7 +52,7 @@ function peer(
|
|
|
51
52
|
gardenId: string,
|
|
52
53
|
backend: MetaCitizenBackend,
|
|
53
54
|
liveness: FactLiveness,
|
|
54
|
-
observed: Partial<Pick<PeerFact, "receiver" | "transcript">> = {},
|
|
55
|
+
observed: Partial<Pick<PeerFact, "receiver" | "transcript" | "placement">> = {},
|
|
55
56
|
): PeerFact {
|
|
56
57
|
return {
|
|
57
58
|
gardenId,
|
|
@@ -64,6 +65,7 @@ function peer(
|
|
|
64
65
|
liveness,
|
|
65
66
|
receiver: observed.receiver ?? "n/a",
|
|
66
67
|
transcript: observed.transcript ?? "exists",
|
|
68
|
+
placement: observed.placement ?? UNOBSERVED_PLACEMENT,
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -44,6 +44,7 @@ import * as path from "node:path";
|
|
|
44
44
|
import { fileURLToPath } from "node:url";
|
|
45
45
|
import {
|
|
46
46
|
computeSelfAddressability,
|
|
47
|
+
decideUncitizenedNotice,
|
|
47
48
|
type SelfAddressabilityFacts,
|
|
48
49
|
type SocketState,
|
|
49
50
|
} from "../pi-extensions/lib/entwurf-self-address.ts";
|
|
@@ -512,4 +513,71 @@ ok(
|
|
|
512
513
|
!/import[^;]*from\s*"\.\/lib\/entwurf-self-address\.(js|ts)"/.test(nativeSrc),
|
|
513
514
|
);
|
|
514
515
|
|
|
516
|
+
// ── the other half of the same honesty: a session that is NOT a citizen says so ──
|
|
517
|
+
// This gate exists because a surface must not CLAIM an addressability it does not have. The mirror
|
|
518
|
+
// of that is a surface that has none and says nothing: `[관측: GLG, 날것 PC, 2026-09-17]` a herdr
|
|
519
|
+
// plugin install wires this extension at USER scope, so it loads in every pi on the host while
|
|
520
|
+
// citizenship stays argv-gated — a plain `pi` after a green install is silent, tool-less and
|
|
521
|
+
// indistinguishable from an install that did nothing.
|
|
522
|
+
//
|
|
523
|
+
// WHY THESE THREE CELLS AND NOT ONE REGEX. The first cut pinned "told once, on the UI only" with a
|
|
524
|
+
// source regex that only proved a call existed somewhere. `[측정 2026-09-17, 독립 검수
|
|
525
|
+
// claude-opus-5 + 재현]` two mutants walked through it green: moving the call into the CITIZEN
|
|
526
|
+
// branch, and deleting the once-latch — 51/51 both times. That was a false success, so the
|
|
527
|
+
// conditions moved into a pure decision the truth table below exhausts, and what is left at the
|
|
528
|
+
// call site is narrow enough for a source pin to actually cover.
|
|
529
|
+
{
|
|
530
|
+
const decide = (controlEnabled: boolean, hasUI: boolean, alreadyShown: boolean): boolean =>
|
|
531
|
+
decideUncitizenedNotice({ controlEnabled, hasUI, alreadyShown });
|
|
532
|
+
const table = {
|
|
533
|
+
citizenFirstUI: decide(true, true, false),
|
|
534
|
+
citizenNoUI: decide(true, false, false),
|
|
535
|
+
citizenShown: decide(true, true, true),
|
|
536
|
+
uncitizenedFirstUI: decide(false, true, false),
|
|
537
|
+
uncitizenedNoUI: decide(false, false, false),
|
|
538
|
+
uncitizenedShown: decide(false, true, true),
|
|
539
|
+
uncitizenedShownNoUI: decide(false, false, true),
|
|
540
|
+
};
|
|
541
|
+
ok(
|
|
542
|
+
"a citizen is never told it is not one, a session with no UI is never told anything, and the " +
|
|
543
|
+
`answer is true in exactly ONE of the eight states [QK:SELFADDR-UNCITIZENED-DECISION] (${JSON.stringify(table)})`,
|
|
544
|
+
table.uncitizenedFirstUI === true &&
|
|
545
|
+
table.citizenFirstUI === false &&
|
|
546
|
+
table.citizenNoUI === false &&
|
|
547
|
+
table.citizenShown === false &&
|
|
548
|
+
table.uncitizenedNoUI === false &&
|
|
549
|
+
table.uncitizenedShown === false &&
|
|
550
|
+
table.uncitizenedShownNoUI === false,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The call site carries the REAL flag as a fact and is unconditional — there is no branch left for
|
|
555
|
+
// a mutant to move it into, and passing a constant would make the decision above unreachable.
|
|
556
|
+
const noticeBody = nativeSrc.slice(
|
|
557
|
+
nativeSrc.indexOf("async function noticeUncitizenedSession"),
|
|
558
|
+
nativeSrc.indexOf("function shouldRegisterControlTools"),
|
|
559
|
+
);
|
|
560
|
+
ok(
|
|
561
|
+
"pi-native: the not-a-citizen notice is decided from the REAL flag, on the UI only, never on stderr " +
|
|
562
|
+
"[QK:SELFADDR-UNCITIZENED-NOTICED]",
|
|
563
|
+
/const enabled = pi\.getFlag\(ENTWURF_FLAG\) === true;\n\t\tawait noticeUncitizenedSession\(ctx, enabled\);/.test(
|
|
564
|
+
nativeSrc,
|
|
565
|
+
) &&
|
|
566
|
+
/decideUncitizenedNotice\(\{ controlEnabled, hasUI: ctx\.hasUI, alreadyShown: uncitizenedNoticeShown \}\)/.test(
|
|
567
|
+
noticeBody,
|
|
568
|
+
) &&
|
|
569
|
+
/--entwurf-control/.test(noticeBody) &&
|
|
570
|
+
!/process\.stderr/.test(noticeBody),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// The latch is the difference between a hint and a nag: `refreshServer` runs again on session
|
|
574
|
+
// switches, so the line that records "already said it" is load-bearing, not bookkeeping.
|
|
575
|
+
ok(
|
|
576
|
+
"pi-native: the notice records that it fired, so a session switch does not repeat it " +
|
|
577
|
+
"[QK:SELFADDR-UNCITIZENED-ONCE]",
|
|
578
|
+
/let uncitizenedNoticeShown = false;/.test(nativeSrc) &&
|
|
579
|
+
/uncitizenedNoticeShown = true;/.test(noticeBody) &&
|
|
580
|
+
noticeBody.indexOf("uncitizenedNoticeShown = true;") < noticeBody.indexOf("ctx.ui.notify("),
|
|
581
|
+
);
|
|
582
|
+
|
|
515
583
|
console.log(`\ncheck-entwurf-self-address: ${passed} checks passed`);
|