@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
|
@@ -72,7 +72,7 @@ interface WireUsage {
|
|
|
72
72
|
* One `_meta.quota.token_count` row. The field NAMES deliberately differ from
|
|
73
73
|
* `PromptResponse.usage`: cache reads are `cachedInputTokens` here because the
|
|
74
74
|
* shape is shared with codex-acp, and `cachedWriteTokens` is Claude's extra
|
|
75
|
-
* sibling (read at claude-agent-acp 0.
|
|
75
|
+
* sibling (read at claude-agent-acp 0.79.0 `dist/acp-agent.js:6579-6588`).
|
|
76
76
|
* Reading a quota row with the `usage` field names silently yields zeros, so the
|
|
77
77
|
* fixture below spells the vendor's names out rather than reusing `WireUsage`.
|
|
78
78
|
*/
|
|
@@ -137,7 +137,7 @@ function makeFakeChild() {
|
|
|
137
137
|
*
|
|
138
138
|
* Each `prompt` call consumes the next TurnScript: it first pushes that turn's
|
|
139
139
|
* `usage_update` notification (the wire the running cost total actually arrives
|
|
140
|
-
* on — read at claude-agent-acp 0.
|
|
140
|
+
* on — read at claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), then answers the
|
|
141
141
|
* prompt with that turn's `PromptResponse.usage` (the wire the turn aggregate
|
|
142
142
|
* arrives on). Both orderings are the real one: the notification precedes the
|
|
143
143
|
* response, because the SDK emits it from the `result` message that ENDS the turn.
|
|
@@ -393,8 +393,8 @@ try {
|
|
|
393
393
|
// AGENT LOOP only. `_meta.quota.model_usage` comes from `result.modelUsage` and
|
|
394
394
|
// also counts Task subagents, sidechains and INTERNAL CALLS SUCH AS COMPACTION;
|
|
395
395
|
// the vendor states its rows "can total more than `token_count`" and are "the
|
|
396
|
-
// fuller picture, not a decomposition of it" (read at claude-agent-acp 0.
|
|
397
|
-
// `dist/acp-agent.js:
|
|
396
|
+
// fuller picture, not a decomposition of it" (read at claude-agent-acp 0.79.0
|
|
397
|
+
// `dist/acp-agent.js:6554-6560`).
|
|
398
398
|
//
|
|
399
399
|
// The wide one is required, not merely nicer, because the DENOMINATOR already
|
|
400
400
|
// has that scope: turn cost is the adjacent diff of the backend's running total,
|
|
@@ -608,8 +608,8 @@ try {
|
|
|
608
608
|
// identity that produces the bound is a property of the MAIN AGENT LOOP's
|
|
609
609
|
// cache breakpoints. `_meta.quota.model_usage` is a WIDER scope: the vendor
|
|
610
610
|
// states those rows also count Task subagents, sidechains, and INTERNAL
|
|
611
|
-
// CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.
|
|
612
|
-
// `dist/acp-agent.js:
|
|
611
|
+
// CALLS SUCH AS COMPACTION (read at claude-agent-acp 0.79.0
|
|
612
|
+
// `dist/acp-agent.js:6551-6571`). Mixing the two scopes inflates the bound
|
|
613
613
|
// through both remaining terms that mention cacheWrite:
|
|
614
614
|
// max(0, occupancy − cacheWrite) shrinks as wide cacheWrite grows, so
|
|
615
615
|
// less is subtracted; min(rawBound, cacheWrite) rises with it.
|
|
@@ -749,9 +749,9 @@ try {
|
|
|
749
749
|
// CELL 3 — a turn with NO cost notification holds the baseline.
|
|
750
750
|
//
|
|
751
751
|
// Measured upstream: the result-path `usage_update` carries cost (read at
|
|
752
|
-
// claude-agent-acp 0.
|
|
752
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:3504-3519`), while other
|
|
753
753
|
// `usage_update` paths can carry `used` without cost (for example the
|
|
754
|
-
// rate-limit path at `:
|
|
754
|
+
// rate-limit path at `:4336-4344`). A live thinkpad ledger shows such turns
|
|
755
755
|
// really occur. The honest handling is to HOLD the baseline so the amount lands
|
|
756
756
|
// in the NEXT diff: misattributed by turn, exact by session. Rebaselining to 0
|
|
757
757
|
// there would double-count the whole prefix.
|
|
@@ -831,7 +831,7 @@ try {
|
|
|
831
831
|
// CELL 4 — a DECREASING session total is never silently absorbed.
|
|
832
832
|
//
|
|
833
833
|
// `conversation_reset` switches the session to a fresh transcript (read at
|
|
834
|
-
// claude-agent-acp 0.
|
|
834
|
+
// claude-agent-acp 0.79.0 `dist/acp-agent.js:4349-4356`), but whether that
|
|
835
835
|
// changes `total_cost_usd` is an SDK-internal value we cannot observe here.
|
|
836
836
|
// A diff can therefore go negative in a session we are still holding.
|
|
837
837
|
// Absorbing it quietly would both misreport the turn and destroy the only
|
|
@@ -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);
|
|
@@ -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`);
|