@junghanacs/entwurf 0.35.1 → 0.36.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.
Files changed (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +403 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +17 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +12 -10
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +69 -5
  10. package/docs/setup-clean-host.md +30 -22
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +59 -14
  15. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  16. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  17. package/package.json +21 -16
  18. package/pi/pi-durable/bootstrap.mjs +12 -8
  19. package/pi/pi-durable/carrier/runtime.js +52 -2
  20. package/pi/pi-durable/carrier/subagent.js +6 -1
  21. package/pi/pi-durable/carrier/tui.js +31 -4
  22. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  23. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  24. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  25. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  26. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  27. package/pi-extensions/meta-bridge-pi-durable.ts +77 -21
  28. package/run.sh +116 -39
  29. package/scripts/check-acp-prompt-lifecycle.ts +4 -2
  30. package/scripts/check-acp-session-reuse.ts +4 -2
  31. package/scripts/check-acp-stop-reason.ts +4 -2
  32. package/scripts/check-acp-stream-hooks.ts +4 -2
  33. package/scripts/check-acp-usage-accounting.ts +4 -2
  34. package/scripts/check-codex-app-server-launch.ts +55 -12
  35. package/scripts/check-codex-native-push.ts +231 -12
  36. package/scripts/check-gate-qualification.ts +4 -4
  37. package/scripts/check-herdr-supply.ts +32 -7
  38. package/scripts/codex-app-server-launch.sh +54 -29
  39. package/scripts/codex-socket-classify.ts +46 -0
  40. package/scripts/emit-pi-durable-carrier.ts +159 -168
  41. package/scripts/fixtures/herdr-supply.json +10 -9
  42. package/scripts/lib/acp-gate-home.ts +75 -0
  43. package/scripts/lib/codex-socket-listener.ts +429 -0
  44. package/scripts/mutants/acp-stop-reason.json +11 -0
  45. package/scripts/mutants/codex-app-server-launch.json +39 -12
  46. package/scripts/mutants/codex-native.json +67 -0
  47. package/scripts/mutants/control-send-receipt.json +1 -1
  48. package/scripts/mutants/herdr-supply.json +6 -6
  49. package/scripts/mutants/pack-install.json +3 -3
  50. package/scripts/mutants/pi-durable.json +165 -16
  51. package/scripts/mutants/pi-floor.json +5 -5
  52. package/scripts/pi-durable-contact-host.mjs +1 -1
  53. package/scripts/smoke-codex-fresh-live.ts +31 -7
  54. package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
  55. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -0,0 +1,429 @@
1
+ /**
2
+ * codex-socket-listener — does the operator-named Codex app-server's OWN process subtree hold the
3
+ * LISTEN socket at the control endpoint Entwurf actually connects to? (#128 CP-A1)
4
+ *
5
+ * WHY THIS EXISTS, measured. `smoke-codex-fresh-live` asserted "the explicit app-server PID owns
6
+ * the same default UDS" by comparing argv against `--listen unix://<resolved default path>`. That
7
+ * is a statement of intent, and the two hosts that ran it measured the difference:
8
+ *
9
+ * - oracle, the 0.36.0 cut (`[측정 2026-10-14]`, cleanup receipts codex-procs-before.txt and
10
+ * codex-socket-before.txt): the named pid 851181 was the pnpm node WRAPPER, and its argv
11
+ * matched. The LISTEN socket was held by its child 851228, the native binary, at the 0.162.1
12
+ * physical path under `/tmp/codex-daemon-<euid>/`. The check passed on a process that held
13
+ * no socket.
14
+ * - thinkpad (#128 comment 6157960561): the named pid WAS the listener, but it was started with
15
+ * `--listen unix://` (the vendor default, no path), so the check refused it.
16
+ *
17
+ * So ownership is read from the kernel, never from argv. The endpoint is not resolved here: the
18
+ * caller passes the `connectPath` of the production classifier `checkCodexSocketFile`, the same
19
+ * socket delivery connects to. This leaf adds no alias rule and has no path seam that could stand
20
+ * in for that check.
21
+ *
22
+ * Two halves, kept apart on purpose:
23
+ *
24
+ * OBSERVE (read-only IO, real `/proc` only). It reads the named process's own network
25
+ * namespace table (`/proc/<pid>/net/unix`) and keeps only the LISTEN stream rows bound at
26
+ * exactly the endpoint. With exactly one such row, it walks DOWN from the named pid alone: the
27
+ * kernel's children lists, each child's parent re-read from its own `stat`, and `fd` links
28
+ * compared against that one inode. Then a bounded SECOND read: every walked node's `stat`
29
+ * again (parent and start time), and the `fd` links again of each node that held the inode.
30
+ * The table is read last. There is no host-wide pid search, no environment read and no reach
31
+ * above or beside the named pid.
32
+ *
33
+ * JUDGE (pure). Positive ONLY when exactly one process inside that subtree held exactly that
34
+ * inode, still holds it on the second read, its parent chain reaches the named pid through
35
+ * observed nodes, and the named pid is still the process whose identity the caller checked
36
+ * (its start time, captured by the caller BEFORE those checks). Descent alone is not
37
+ * evidence — the held inode is. Everything else fails closed, and the reason says which kind
38
+ * of failure it is:
39
+ * not-owned a LISTEN row exists and NO process in the named subtree holds it. This does
40
+ * not name a foreign owner; nothing outside the subtree was read.
41
+ * indeterminate zero or several rows, several holders, an unreadable or unrecognised entry,
42
+ * or a pid, parent, fd or row that moved between the reads. None of these say
43
+ * who owns the socket, so none of them may read as an answer either way.
44
+ *
45
+ * What a positive does NOT mean: a self-listener (thinkpad shape) being owned says nothing
46
+ * about whether a vendor-managed daemon is an admitted operator shape, nor about the tmux seat
47
+ * the same smoke checks separately. Those stay their own contracts.
48
+ */
49
+ import * as fs from "node:fs";
50
+
51
+ /** One LISTEN-state stream socket bound at exactly the endpoint, as a kernel table row. */
52
+ export interface UnixListenRow {
53
+ readonly inode: string;
54
+ }
55
+
56
+ export class UnixTableError extends Error {}
57
+
58
+ const TABLE_HEADER = /^Num\s+RefCount\s+Protocol\s+Flags\s+Type\s+St\s+Inode(?:\s+Path)?\s*$/;
59
+ // `%pK: %08X %08X %08X %04X %02X %5lu` and then ` <path>` when the socket is bound.
60
+ const TABLE_ROW =
61
+ /^[0-9A-Fa-f]+:\s+[0-9A-Fa-f]{8}\s+[0-9A-Fa-f]{8}\s+([0-9A-Fa-f]{8})\s+([0-9A-Fa-f]{4})\s+[0-9A-Fa-f]{2}\s+(\d+)(?: (.*))?$/;
62
+ const SO_ACCEPTCON = 0x10000;
63
+ const SOCK_STREAM = 0x0001;
64
+
65
+ /**
66
+ * The LISTEN stream rows of a `/proc/<pid>/net/unix` table whose path is byte-equal to
67
+ * `endpoint`. A connected row at the same path, a datagram socket, a path that only shares a
68
+ * prefix and an abstract socket are not rows of this endpoint. A table this parser does not
69
+ * recognise throws rather than being read around.
70
+ */
71
+ export function listenRowsAt(table: string, endpoint: string): UnixListenRow[] {
72
+ const lines = table.split("\n");
73
+ if (!TABLE_HEADER.test(lines[0] ?? "")) {
74
+ throw new UnixTableError(`unrecognised unix socket table header: ${JSON.stringify(lines[0] ?? "")}`);
75
+ }
76
+ const rows: UnixListenRow[] = [];
77
+ for (const line of lines.slice(1)) {
78
+ if (line === "") continue;
79
+ const match = TABLE_ROW.exec(line.trimStart());
80
+ if (match === null) throw new UnixTableError(`unrecognised unix socket table row: ${JSON.stringify(line)}`);
81
+ const [, flags, type, inode, boundPath] = match;
82
+ if (boundPath !== endpoint) continue;
83
+ if ((Number.parseInt(flags ?? "", 16) & SO_ACCEPTCON) === 0) continue;
84
+ if (Number.parseInt(type ?? "", 16) !== SOCK_STREAM) continue;
85
+ rows.push({ inode: inode as string });
86
+ }
87
+ return rows;
88
+ }
89
+
90
+ export interface ProcStat {
91
+ readonly comm: string;
92
+ readonly ppid: number;
93
+ readonly startTime: string;
94
+ }
95
+
96
+ /**
97
+ * `/proc/<pid>/stat`. `comm` may itself contain spaces and parentheses, so the fixed fields are
98
+ * counted from the LAST `)`: field 4 is the parent pid and field 22 the start time in clock
99
+ * ticks since boot — the pair that tells a pid apart from a later process that reused it.
100
+ */
101
+ export function parseProcStat(text: string): ProcStat {
102
+ const open = text.indexOf("(");
103
+ const close = text.lastIndexOf(")");
104
+ if (open < 0 || close < open) throw new Error(`unrecognised stat line: ${JSON.stringify(text)}`);
105
+ const fields = text
106
+ .slice(close + 1)
107
+ .trim()
108
+ .split(/\s+/);
109
+ const ppid = Number(fields[1]);
110
+ const startTime = fields[19] ?? "";
111
+ if (!Number.isInteger(ppid) || ppid < 0 || !/^\d+$/.test(startTime)) {
112
+ throw new Error(`unrecognised stat fields: ${JSON.stringify(text)}`);
113
+ }
114
+ return { comm: text.slice(open + 1, close), ppid, startTime };
115
+ }
116
+
117
+ export interface SubtreeNode {
118
+ readonly pid: number;
119
+ readonly ppid: number;
120
+ readonly comm: string;
121
+ readonly startTime: string;
122
+ /** fd numbers whose link is exactly `socket:[<the endpoint's LISTEN inode>]`. */
123
+ readonly holdingFds: readonly number[];
124
+ /**
125
+ * The second read, after the whole walk. `null` = the pid could not be read again. `holdingFds`
126
+ * is re-read only for a node that held the inode on the first read, and is `null` otherwise.
127
+ */
128
+ readonly after: {
129
+ readonly ppid: number;
130
+ readonly startTime: string;
131
+ readonly holdingFds: readonly number[] | null;
132
+ } | null;
133
+ }
134
+
135
+ export type ObservationFailureReason =
136
+ | "subtree-unreadable"
137
+ | "subtree-changed"
138
+ | "subtree-too-large"
139
+ | "table-malformed";
140
+
141
+ export interface CodexSocketObservation {
142
+ readonly namedPid: number;
143
+ /** The named pid's start time as the CALLER read it before its own identity checks. */
144
+ readonly namedStartTime: string;
145
+ readonly endpoint: string;
146
+ readonly rows: readonly UnixListenRow[];
147
+ readonly rowsAfter: readonly UnixListenRow[];
148
+ /** Breadth-first from the named pid; empty unless exactly one row was found. */
149
+ readonly nodes: readonly SubtreeNode[];
150
+ readonly failure: { readonly reason: ObservationFailureReason; readonly detail: string } | null;
151
+ }
152
+
153
+ export type CodexSocketOwnership =
154
+ | {
155
+ readonly verdict: "owned";
156
+ readonly inode: string;
157
+ /** Named pid first, the listener last. */
158
+ readonly chain: readonly SubtreeNode[];
159
+ readonly fds: readonly number[];
160
+ }
161
+ | { readonly verdict: "not-owned"; readonly reason: "not-held-in-named-subtree"; readonly detail: string }
162
+ | {
163
+ readonly verdict: "indeterminate";
164
+ readonly reason:
165
+ | ObservationFailureReason
166
+ | "no-listen-row"
167
+ | "several-listen-rows"
168
+ | "listen-row-changed"
169
+ | "named-identity-moved"
170
+ | "several-holders"
171
+ | "listener-fd-released"
172
+ | "chain-broken";
173
+ readonly detail: string;
174
+ };
175
+
176
+ export function judgeCodexSocketOwnership(o: CodexSocketObservation): CodexSocketOwnership {
177
+ if (o.failure !== null) return { verdict: "indeterminate", reason: o.failure.reason, detail: o.failure.detail };
178
+ if (o.rows.length === 0) {
179
+ return {
180
+ verdict: "indeterminate",
181
+ reason: "no-listen-row",
182
+ detail:
183
+ `no LISTEN stream socket in pid ${o.namedPid}'s network namespace is bound at exactly ${o.endpoint}; ` +
184
+ "ownership cannot be read, and the absence does not name another owner",
185
+ };
186
+ }
187
+ if (o.rows.length > 1) {
188
+ return {
189
+ verdict: "indeterminate",
190
+ reason: "several-listen-rows",
191
+ detail: `${o.rows.length} LISTEN rows are bound at ${o.endpoint} (inodes ${o.rows.map((r) => r.inode).join(", ")})`,
192
+ };
193
+ }
194
+ const inode = (o.rows[0] as UnixListenRow).inode;
195
+ if (o.rowsAfter.length !== 1 || o.rowsAfter[0]?.inode !== inode) {
196
+ return {
197
+ verdict: "indeterminate",
198
+ reason: "listen-row-changed",
199
+ detail: `the LISTEN row at ${o.endpoint} was inode ${inode} before the fd join and ${JSON.stringify(o.rowsAfter.map((r) => r.inode))} after`,
200
+ };
201
+ }
202
+ const named = o.nodes[0];
203
+ if (named?.pid !== o.namedPid || named.startTime !== o.namedStartTime) {
204
+ return {
205
+ verdict: "indeterminate",
206
+ reason: "named-identity-moved",
207
+ detail:
208
+ `pid ${o.namedPid} started at ${o.namedStartTime} when its identity was checked, but the walk began at ` +
209
+ `${named === undefined ? "no node" : `pid ${named.pid} started at ${named.startTime}`}`,
210
+ };
211
+ }
212
+ const moved = o.nodes.filter((node) => node.after?.startTime !== node.startTime || node.after?.ppid !== node.ppid);
213
+ if (moved.length > 0) {
214
+ return {
215
+ verdict: "indeterminate",
216
+ reason: "subtree-changed",
217
+ detail: `pid(s) ${moved.map((node) => node.pid).join(", ")} exited, were reused or changed parent while the subtree was read`,
218
+ };
219
+ }
220
+ const holders = o.nodes.filter((node) => node.holdingFds.length > 0);
221
+ if (holders.length === 0) {
222
+ return {
223
+ verdict: "not-owned",
224
+ reason: "not-held-in-named-subtree",
225
+ detail:
226
+ `no process in pid ${o.namedPid}'s subtree (${o.nodes.map((node) => node.pid).join(", ")}) holds the LISTEN socket ` +
227
+ `inode ${inode} at ${o.endpoint}; nothing outside that subtree was read, so no owner is named`,
228
+ };
229
+ }
230
+ if (holders.length > 1) {
231
+ return {
232
+ verdict: "indeterminate",
233
+ reason: "several-holders",
234
+ detail: `pids ${holders.map((node) => node.pid).join(", ")} all hold LISTEN inode ${inode}`,
235
+ };
236
+ }
237
+ const listener = holders[0] as SubtreeNode;
238
+ // Same pid, same start time and a stable row are not enough: the holder may have closed or
239
+ // handed off its fd while a process this walk never re-read kept the inode open.
240
+ const heldAfter = listener.after?.holdingFds ?? [];
241
+ if (heldAfter.length === 0) {
242
+ return {
243
+ verdict: "indeterminate",
244
+ reason: "listener-fd-released",
245
+ detail: `pid ${listener.pid} held LISTEN inode ${inode} on fd ${listener.holdingFds.join(",")} but no longer did on the second read`,
246
+ };
247
+ }
248
+ const byPid = new Map(o.nodes.map((node) => [node.pid, node] as const));
249
+ const chain: SubtreeNode[] = [listener];
250
+ for (let node = listener; node.pid !== o.namedPid; ) {
251
+ const parent = byPid.get(node.ppid);
252
+ if (parent === undefined || chain.includes(parent)) {
253
+ return {
254
+ verdict: "indeterminate",
255
+ reason: "chain-broken",
256
+ detail: `listener ${listener.pid}'s parent chain does not reach the named pid ${o.namedPid} through observed nodes`,
257
+ };
258
+ }
259
+ chain.unshift(parent);
260
+ node = parent;
261
+ }
262
+ return { verdict: "owned", inode, chain, fds: heldAfter };
263
+ }
264
+
265
+ /** One line for an assertion detail or a receipt. */
266
+ export function describeCodexSocketOwnership(ownership: CodexSocketOwnership): string {
267
+ if (ownership.verdict !== "owned") return `${ownership.verdict} (${ownership.reason}): ${ownership.detail}`;
268
+ const listener = ownership.chain[ownership.chain.length - 1] as SubtreeNode;
269
+ return (
270
+ `owned: LISTEN inode ${ownership.inode} held by pid ${listener.pid} (${listener.comm}) fd ${ownership.fds.join(",")}; ` +
271
+ `chain ${ownership.chain.map((node) => `${node.pid}(${node.comm})`).join(" -> ")}`
272
+ );
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // OBSERVE — real /proc only
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /** Bound on the walk. The Codex trees measured so far have one to three processes. */
280
+ const SUBTREE_LIMIT = 64;
281
+
282
+ class ObservationFailure extends Error {
283
+ readonly reason: ObservationFailureReason;
284
+ constructor(reason: ObservationFailureReason, detail: string) {
285
+ super(detail);
286
+ this.reason = reason;
287
+ }
288
+ }
289
+
290
+ function failureFrom(error: unknown, what: string): ObservationFailure {
291
+ const code = (error as NodeJS.ErrnoException).code;
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ // A pid, task or fd that disappeared between two reads moved under us. A permission refusal
294
+ // or anything else means the entry could not be read, which is not the same statement.
295
+ if (code === "ENOENT" || code === "ESRCH") return new ObservationFailure("subtree-changed", `${what}: ${message}`);
296
+ return new ObservationFailure("subtree-unreadable", `${what}: ${message}`);
297
+ }
298
+
299
+ function readStat(pid: number): ProcStat {
300
+ try {
301
+ return parseProcStat(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
302
+ } catch (error) {
303
+ throw failureFrom(error, `stat of pid ${pid}`);
304
+ }
305
+ }
306
+
307
+ function readListenRows(namedPid: number, endpoint: string): UnixListenRow[] {
308
+ let table: string;
309
+ try {
310
+ table = fs.readFileSync(`/proc/${namedPid}/net/unix`, "utf8");
311
+ } catch (error) {
312
+ throw failureFrom(error, `unix socket table of pid ${namedPid}`);
313
+ }
314
+ try {
315
+ return listenRowsAt(table, endpoint);
316
+ } catch (error) {
317
+ throw new ObservationFailure("table-malformed", error instanceof Error ? error.message : String(error));
318
+ }
319
+ }
320
+
321
+ function holdingFds(pid: number, target: string): number[] {
322
+ const dir = `/proc/${pid}/fd`;
323
+ let entries: string[];
324
+ try {
325
+ entries = fs.readdirSync(dir);
326
+ } catch (error) {
327
+ throw failureFrom(error, `fd list of pid ${pid}`);
328
+ }
329
+ const held: number[] = [];
330
+ for (const entry of entries) {
331
+ let link: string;
332
+ try {
333
+ link = fs.readlinkSync(`${dir}/${entry}`);
334
+ } catch (error) {
335
+ throw failureFrom(error, `fd ${entry} of pid ${pid}`);
336
+ }
337
+ if (link === target) held.push(Number(entry));
338
+ }
339
+ return held;
340
+ }
341
+
342
+ function childPids(pid: number): number[] {
343
+ let tids: string[];
344
+ try {
345
+ tids = fs.readdirSync(`/proc/${pid}/task`);
346
+ } catch (error) {
347
+ throw failureFrom(error, `task list of pid ${pid}`);
348
+ }
349
+ const children: number[] = [];
350
+ for (const tid of tids) {
351
+ const file = `/proc/${pid}/task/${tid}/children`;
352
+ let text: string;
353
+ try {
354
+ text = fs.readFileSync(file, "utf8");
355
+ } catch (error) {
356
+ // The task still being there while its children list is not means this kernel does not
357
+ // expose one, which is unreadable rather than a process that moved.
358
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && fs.existsSync(`/proc/${pid}/task/${tid}`)) {
359
+ throw new ObservationFailure("subtree-unreadable", `${file}: this kernel exposes no children list`);
360
+ }
361
+ throw failureFrom(error, file);
362
+ }
363
+ for (const token of text.trim().split(/\s+/)) if (token !== "") children.push(Number(token));
364
+ }
365
+ return children;
366
+ }
367
+
368
+ function statOrNull(pid: number): ProcStat | null {
369
+ try {
370
+ return parseProcStat(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
371
+ } catch {
372
+ // Bounded probe: a pid that is gone answers null, and the judge reads null as moved.
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * @param namedStartTime the named pid's start time as the caller read it BEFORE its own identity
379
+ * checks (uid, argv, environment), so this observation is bound to the process those checks read.
380
+ */
381
+ export function observeCodexSocketSubtree(
382
+ namedPid: number,
383
+ endpoint: string,
384
+ namedStartTime: string,
385
+ ): CodexSocketObservation {
386
+ const base = { namedPid, namedStartTime, endpoint };
387
+ let rows: UnixListenRow[] = [];
388
+ try {
389
+ rows = readListenRows(namedPid, endpoint);
390
+ if (rows.length !== 1) return { ...base, rows, rowsAfter: rows, nodes: [], failure: null };
391
+ const target = `socket:[${(rows[0] as UnixListenRow).inode}]`;
392
+ const visited: Array<Omit<SubtreeNode, "after">> = [];
393
+ const queue: Array<{ pid: number; parent: number | null }> = [{ pid: namedPid, parent: null }];
394
+ while (queue.length > 0) {
395
+ const next = queue.shift() as { pid: number; parent: number | null };
396
+ if (visited.length >= SUBTREE_LIMIT) {
397
+ throw new ObservationFailure(
398
+ "subtree-too-large",
399
+ `pid ${namedPid}'s subtree exceeds ${SUBTREE_LIMIT} processes`,
400
+ );
401
+ }
402
+ const stat = readStat(next.pid);
403
+ if (next.parent !== null && stat.ppid !== next.parent) {
404
+ throw new ObservationFailure(
405
+ "subtree-changed",
406
+ `pid ${next.pid} was listed as a child of ${next.parent} but its stat names parent ${stat.ppid}`,
407
+ );
408
+ }
409
+ const fds = holdingFds(next.pid, target);
410
+ for (const child of childPids(next.pid)) queue.push({ pid: child, parent: next.pid });
411
+ visited.push({ pid: next.pid, ppid: stat.ppid, comm: stat.comm, startTime: stat.startTime, holdingFds: fds });
412
+ }
413
+ // The second read is bounded to what the first one found: every walked node's stat, and the
414
+ // fds of the nodes that held the inode. The table is read last, after both.
415
+ const nodes: SubtreeNode[] = visited.map((node) => {
416
+ const stat = statOrNull(node.pid);
417
+ if (stat === null) return { ...node, after: null };
418
+ const held = node.holdingFds.length > 0 ? holdingFds(node.pid, target) : null;
419
+ return { ...node, after: { ppid: stat.ppid, startTime: stat.startTime, holdingFds: held } };
420
+ });
421
+ const rowsAfter = readListenRows(namedPid, endpoint);
422
+ return { ...base, rows, rowsAfter, nodes, failure: null };
423
+ } catch (error) {
424
+ // Every read above converts its own failure. Anything else is a defect here, not a
425
+ // fact about the subtree, and crashes instead of becoming a verdict.
426
+ if (!(error instanceof ObservationFailure)) throw error;
427
+ return { ...base, rows, rowsAfter: [], nodes: [], failure: { reason: error.reason, detail: error.message } };
428
+ }
429
+ }
@@ -75,6 +75,17 @@
75
75
  "timeoutSeconds": 120,
76
76
  "signature": "[QK:STOP-PENDING-SEED]",
77
77
  "signatureSource": "scripts/check-acp-stop-reason.ts"
78
+ },
79
+ {
80
+ "claim": "ACP-GATE-HOME-SANDBOXED",
81
+ "title": "the ACP gate sandbox leaves HOME on the operator's home — the compiled backend resolves the Claude overlay there, so its real ensureOverlay() would refresh the operator's overlay",
82
+ "subject": "scripts/lib/acp-gate-home.ts",
83
+ "find": ["\tprocess.env.HOME = home;"],
84
+ "replace": ["\t// HOME left on the operator's home"],
85
+ "gate": ["bash", "run.sh", "check-acp-stop-reason"],
86
+ "timeoutSeconds": 120,
87
+ "signature": "[QK:ACP-GATE-HOME-SANDBOXED]",
88
+ "signatureSource": "scripts/lib/acp-gate-home.ts"
78
89
  }
79
90
  ]
80
91
  }
@@ -20,7 +20,8 @@
20
20
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
21
21
  "timeoutSeconds": 300,
22
22
  "signature": "[QK:CODEX-APP-SERVER-ADDRESS-MATCHES-PRODUCT-LEAF]",
23
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
23
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
24
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
24
25
  },
25
26
  {
26
27
  "claim": "CODEX-APP-SERVER-EXECS-NOT-FORKS",
@@ -31,7 +32,8 @@
31
32
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
32
33
  "timeoutSeconds": 300,
33
34
  "signature": "[QK:CODEX-APP-SERVER-EXECS-NOT-FORKS]",
34
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
35
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
36
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
35
37
  },
36
38
  {
37
39
  "claim": "CODEX-APP-SERVER-REFUSES-LIVE-SOCKET",
@@ -42,18 +44,20 @@
42
44
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
43
45
  "timeoutSeconds": 300,
44
46
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-LIVE-SOCKET]",
45
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
47
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
48
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
46
49
  },
47
50
  {
48
51
  "claim": "CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET",
49
- "title": "reading an unidentifiable path (symlink, foreign owner, not a socket) as an absent one lets the launch clobber something it could not identify \u2014 the same classification the delivery rail refuses on, silently downgraded at the one place that would overwrite it",
52
+ "title": "reading the classifier's indeterminate verdict (a symlink that is not Codex's exact alias, a foreign owner, not a socket) as an absent path lets the launch clobber something it could not identify \u2014 the same classification the delivery rail refuses on, silently downgraded at the one place that would overwrite it",
50
53
  "subject": "scripts/codex-app-server-launch.sh",
51
- "find": ["\tprint(\"indeterminate\\tthe path is a symlink\")"],
52
- "replace": ["\tprint(\"absent\")"],
54
+ "find": ["\tindeterminate) probe=\"indeterminate\t$classified_detail\" ;;"],
55
+ "replace": ["\tindeterminate) probe=\"absent\" ;;"],
53
56
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
54
57
  "timeoutSeconds": 300,
55
58
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-INDETERMINATE-SOCKET]",
56
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
59
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
60
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
57
61
  },
58
62
  {
59
63
  "claim": "CODEX-APP-SERVER-REFUSES-SECOND-LISTEN",
@@ -72,7 +76,8 @@
72
76
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
73
77
  "timeoutSeconds": 300,
74
78
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-SECOND-LISTEN]",
75
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
79
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
80
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
76
81
  },
77
82
  {
78
83
  "claim": "CODEX-APP-SERVER-READS-PROC-HOLDER",
@@ -94,7 +99,8 @@
94
99
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
95
100
  "timeoutSeconds": 300,
96
101
  "signature": "[QK:CODEX-APP-SERVER-READS-PROC-HOLDER]",
97
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
102
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
103
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
98
104
  },
99
105
  {
100
106
  "claim": "CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE",
@@ -115,7 +121,26 @@
115
121
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
116
122
  "timeoutSeconds": 300,
117
123
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-PROBE]",
118
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
124
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
125
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
126
+ },
127
+ {
128
+ "claim": "CODEX-APP-SERVER-REFUSES-UNRECOGNISED-CLASSIFIER",
129
+ "title": "passing an unrecognised classifier word through to the probe verdicts lets a word the classifier never gives \u2014 'stale' \u2014 be read as a dead socket file and launched over, so a classifier that answers something nobody wrote decides that this launch may clobber the endpoint",
130
+ "subject": "scripts/codex-app-server-launch.sh",
131
+ "find": [
132
+ "\t*)",
133
+ "\t\tfail \"codex-app-server-socket-probe-unrecognised: the socket classifier answered",
134
+ " '$classified_word', which is not one of socket/absent/dangling/indeterminate. Refusing to launch on a",
135
+ " reading nothing understands.\"",
136
+ "\t\t;;"
137
+ ],
138
+ "replace": ["\t*) probe=\"$classified_word\" ;;"],
139
+ "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
140
+ "timeoutSeconds": 300,
141
+ "signature": "[QK:CODEX-APP-SERVER-REFUSES-UNRECOGNISED-CLASSIFIER]",
142
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
143
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
119
144
  },
120
145
  {
121
146
  "claim": "CODEX-APP-SERVER-REFUSES-RELATIVE-SOCKET",
@@ -135,7 +160,8 @@
135
160
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
136
161
  "timeoutSeconds": 300,
137
162
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-RELATIVE-SOCKET]",
138
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
163
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
164
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
139
165
  },
140
166
  {
141
167
  "claim": "CODEX-APP-SERVER-REFUSES-CONTROL-CHAR-SOCKET",
@@ -151,7 +177,8 @@
151
177
  "gate": ["bash", "run.sh", "check-codex-app-server-launch"],
152
178
  "timeoutSeconds": 300,
153
179
  "signature": "[QK:CODEX-APP-SERVER-REFUSES-CONTROL-CHAR-SOCKET]",
154
- "signatureSource": "scripts/check-codex-app-server-launch.ts"
180
+ "signatureSource": "scripts/check-codex-app-server-launch.ts",
181
+ "build": { "argv": ["bash", "scripts/build-bridge.sh"], "output": "mcp/entwurf-bridge/dist" }
155
182
  }
156
183
  ]
157
184
  }
@@ -28,6 +28,21 @@
28
28
  "signature": "[QK:CODEX-NATIVE-LOADED-PROBE]",
29
29
  "signatureSource": "scripts/check-codex-native-push.ts"
30
30
  },
31
+ {
32
+ "claim": "CODEX-SOCKET-ALIAS-EXACT",
33
+ "title": "accepting any symlink into Codex's socket directory instead of the one target Codex derives for THIS advertised path lets a retargeted alias hand delivery, preflight and the launcher a socket that belongs to a different listen address \u2014 the exact-target equality is the whole of what makes the 0.162.1 alias trustworthy",
34
+ "subject": "pi-extensions/lib/native-push/codex-ws-client.ts",
35
+ "find": [
36
+ "\tif (target !== expected) {",
37
+ "\t\treturn refuse(`app-server socket is a symlink that is not Codex's rendezvous alias: ${socketPath} -> ${target}`);",
38
+ "\t}"
39
+ ],
40
+ "replace": ["\t// any target inside Codex's socket directory is good enough"],
41
+ "gate": ["bash", "run.sh", "check-codex-native-push"],
42
+ "timeoutSeconds": 120,
43
+ "signature": "[QK:CODEX-SOCKET-ALIAS-EXACT]",
44
+ "signatureSource": "scripts/check-codex-native-push.ts"
45
+ },
31
46
  {
32
47
  "claim": "CODEX-NATIVE-VENDOR-QUEUE",
33
48
  "title": "native delivery invokes a different Codex subcommand instead of the measured one-shot queue surface",
@@ -999,6 +1014,58 @@
999
1014
  "timeoutSeconds": 300,
1000
1015
  "signature": "[QK:CODEX-BIRTH-INVERSE-SOLE-OWNER]",
1001
1016
  "signatureSource": "scripts/smoke-codex-birth.sh"
1017
+ },
1018
+ {
1019
+ "claim": "CODEX-SOCKET-OWNER-HELD-INODE",
1020
+ "title": "the named pid counts as the socket's holder for being named, so a wrapper whose subtree holds no fd on the LISTEN inode reads as owning it again",
1021
+ "subject": "scripts/lib/codex-socket-listener.ts",
1022
+ "find": ["\tconst holders = o.nodes.filter((node) => node.holdingFds.length > 0);"],
1023
+ "replace": [
1024
+ "\tconst holders = o.nodes.filter((node) => node.holdingFds.length > 0 || node.pid === o.namedPid); // mutant: named means holding"
1025
+ ],
1026
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
1027
+ "timeoutSeconds": 180,
1028
+ "signature": "[QK:CODEX-SOCKET-OWNER-HELD-INODE]",
1029
+ "signatureSource": "test/codex-socket-listener.test.ts"
1030
+ },
1031
+ {
1032
+ "claim": "CODEX-SOCKET-OWNER-PID-REUSE",
1033
+ "title": "only a vanished pid counts as moved, so a pid reused by another process between the two reads keeps the first process's ownership",
1034
+ "subject": "scripts/lib/codex-socket-listener.ts",
1035
+ "find": [
1036
+ "\tconst moved = o.nodes.filter((node) => node.after?.startTime !== node.startTime || node.after?.ppid !== node.ppid);"
1037
+ ],
1038
+ "replace": [
1039
+ "\tconst moved = o.nodes.filter((node) => node.after === null || node.after.ppid !== node.ppid); // mutant: reuse is not movement"
1040
+ ],
1041
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
1042
+ "timeoutSeconds": 180,
1043
+ "signature": "[QK:CODEX-SOCKET-OWNER-PID-REUSE]",
1044
+ "signatureSource": "test/codex-socket-listener.test.ts"
1045
+ },
1046
+ {
1047
+ "claim": "CODEX-SOCKET-OWNER-FD-REREAD",
1048
+ "title": "the first fd read stands in for the second, so a holder that closed or handed off the LISTEN fd while another process kept the inode still reads as owning it",
1049
+ "subject": "scripts/lib/codex-socket-listener.ts",
1050
+ "find": ["\tconst heldAfter = listener.after?.holdingFds ?? [];"],
1051
+ "replace": [
1052
+ "\tconst heldAfter = listener.after?.holdingFds?.length ? listener.after.holdingFds : listener.holdingFds; // mutant: the first read stands in"
1053
+ ],
1054
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
1055
+ "timeoutSeconds": 180,
1056
+ "signature": "[QK:CODEX-SOCKET-OWNER-FD-REREAD]",
1057
+ "signatureSource": "test/codex-socket-listener.test.ts"
1058
+ },
1059
+ {
1060
+ "claim": "CODEX-SOCKET-LISTEN-ROW-ONLY",
1061
+ "title": "an accepted connection, which carries the listener's path without ACCEPTCON, is read as a second LISTEN row, so any attached client turns ownership indeterminate",
1062
+ "subject": "scripts/lib/codex-socket-listener.ts",
1063
+ "find": ["\t\tif ((Number.parseInt(flags ?? \"\", 16) & SO_ACCEPTCON) === 0) continue;"],
1064
+ "replace": ["\t\t// mutant: every bound row at the path counts, listening or not"],
1065
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
1066
+ "timeoutSeconds": 180,
1067
+ "signature": "[QK:CODEX-SOCKET-LISTEN-ROW-ONLY]",
1068
+ "signatureSource": "test/codex-socket-listener.test.ts"
1002
1069
  }
1003
1070
  ]
1004
1071
  }
@@ -181,7 +181,7 @@
181
181
  "claim": "PI-CORE-PIN-TRACKS-FLOOR",
182
182
  "title": "the queue oracle's pi-agent-core pin drifts off the floor the rest of the pi runtime is pinned to, so the oracle keeps answering honestly — about a pi nobody in this repo runs",
183
183
  "subject": "package.json",
184
- "find": ["\"@earendil-works/pi-agent-core\": \"1.1.0\","],
184
+ "find": ["\"@earendil-works/pi-agent-core\": \"1.2.0\","],
185
185
  "replace": ["\"@earendil-works/pi-agent-core\": \"0.87.1\","],
186
186
  "gate": ["bash", "run.sh", "check-dep-versions"],
187
187
  "timeoutSeconds": 300,