@junghanacs/entwurf 0.23.0 → 0.24.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.
Files changed (102) hide show
  1. package/AGENTS.md +3 -1
  2. package/BASELINE.md +2 -0
  3. package/CHANGELOG.md +307 -0
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +43 -2
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +97 -18
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  21. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  22. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  23. package/mcp/entwurf-bridge/src/index.ts +41 -1
  24. package/mcp/entwurf-bridge/test.sh +2 -2
  25. package/package.json +15 -13
  26. package/pi-extensions/entwurf-control.ts +56 -0
  27. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  28. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  29. package/pi-extensions/lib/acp/backend.ts +20 -10
  30. package/pi-extensions/lib/acp/context.ts +30 -10
  31. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  32. package/pi-extensions/lib/acp/session-store.ts +2 -2
  33. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  34. package/pi-extensions/lib/callback-env.ts +78 -0
  35. package/pi-extensions/lib/compaction-send-guard.ts +4 -1
  36. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  37. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  38. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  39. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  40. package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
  41. package/pi-extensions/lib/herdr-placement.ts +13 -4
  42. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  43. package/pi-extensions/meta-bridge-omp.ts +35 -48
  44. package/run.sh +327 -246
  45. package/scripts/check-acp-backend-preflight.ts +117 -9
  46. package/scripts/check-acp-carrier-augment.ts +6 -4
  47. package/scripts/check-acp-event-mapper.ts +17 -6
  48. package/scripts/check-acp-prompt-builder.ts +17 -9
  49. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  50. package/scripts/check-acp-session-reuse.ts +64 -51
  51. package/scripts/check-acp-session-store.ts +31 -8
  52. package/scripts/check-acp-stop-reason.ts +20 -2
  53. package/scripts/check-acp-stream-hooks.ts +22 -5
  54. package/scripts/check-acp-usage-accounting.ts +42 -18
  55. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  56. package/scripts/check-fresh-call-dispatch.ts +4 -5
  57. package/scripts/check-gate-qualification.ts +45 -5
  58. package/scripts/check-herdr-activation.ts +73 -0
  59. package/scripts/check-herdr-fresh-call.ts +22 -6
  60. package/scripts/check-herdr-sandbox.ts +14 -2
  61. package/scripts/check-install-container.sh +1 -1
  62. package/scripts/check-pi-launch.ts +361 -0
  63. package/scripts/check-release-gate-outcomes.ts +7 -0
  64. package/scripts/inventory-verification-surface.ts +86 -10
  65. package/scripts/lib/mutation-qualify.ts +1 -1
  66. package/scripts/mutants/acp-backend-preflight.json +20 -0
  67. package/scripts/mutants/bridge-boot-resume.json +26 -0
  68. package/scripts/mutants/herdr-activation.json +16 -0
  69. package/scripts/mutants/herdr-fresh-call.json +11 -0
  70. package/scripts/mutants/herdr-plugin-build.json +1 -1
  71. package/scripts/mutants/mux-fresh-call.json +21 -8
  72. package/scripts/mutants/omp-fresh.json +5 -9
  73. package/scripts/mutants/pack-install.json +3 -3
  74. package/scripts/mutants/pi-launch.json +113 -0
  75. package/scripts/mutants/resume-args.json +6 -6
  76. package/scripts/mutants/v2-surface.json +2 -2
  77. package/scripts/mutants/v2-visible-resume.json +17 -17
  78. package/scripts/probe-bridge-command.ts +1 -0
  79. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  81. package/scripts/smoke-agy-install-state.sh +2 -2
  82. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  83. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  84. package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
  85. package/scripts/smoke-herdr-raw-install-live.sh +877 -0
  86. package/scripts/smoke-omp-fresh-live.ts +4 -2
  87. package/scripts/smoke-omp-mcp-state.sh +1 -1
  88. package/scripts/smoke-pi-provider-state.sh +2 -2
  89. package/scripts/tsconfig.json +5 -0
  90. package/scripts/check-entwurf-facts.ts +0 -391
  91. package/scripts/check-entwurf-resume-args.ts +0 -122
  92. package/scripts/check-entwurf-v2-decider.ts +0 -657
  93. package/scripts/check-entwurf-v2-lock.ts +0 -371
  94. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  95. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  96. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  97. package/scripts/check-entwurf-v2-release.ts +0 -151
  98. package/scripts/check-entwurf-v2-runner.ts +0 -448
  99. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  100. package/scripts/check-entwurf-v2-send.ts +0 -458
  101. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  102. package/scripts/check-socket-discovery.ts +0 -401
@@ -1,401 +0,0 @@
1
- /**
2
- * check-socket-discovery — deterministic gate for the fact-provider's SOCKET
3
- * axis (0.11 Stage 0 step 4, slice 3). Drives `scanSocketProbes` with injected
4
- * readdir/probe (no IO) and proves the slice-2 contract is fed correctly:
5
- *
6
- * - union: every dir-present socket AND every in-domain citizen is probed,
7
- * - dormant trap: a pi citizen with NO socket file reads `dead` (ENOENT), so
8
- * downstream it routes dormant→resumable (never an unprobed gap),
9
- * - F3 preserve: a stalled socket reads `indeterminate`, never folded to dead,
10
- * - dir hygiene: non-`.sock` names ignored; malformed (non-garden-id) names
11
- * are VISIBLY dropped (`malformedNames`), not silently (P3),
12
- * - symlink guard (P1): a gid-shaped `*.sock` symlink is never probed — a
13
- * citizen owning one is forced `dead`, a record-less one dropped — and is
14
- * surfaced in `symlinkedGardenIds`,
15
- * - dir-read error (P2e②): ENOENT → `dirError=null` (normal empty); any other
16
- * failure → `dirError` set (socket axis loss surfaced, not swallowed),
17
- * - dedup: a gid present in BOTH the dir and the citizen list is probed once,
18
- * - missing dir: citizens are still probed (→ dead),
19
- * - determinism: output sorted by gardenId,
20
- * - #50 C4: a probe is gid + liveness ONLY (keyset pinned — the get_info enrich
21
- * went with the socket-only quasi-citizen listing),
22
- * - end-to-end: scanSocketProbes → resolveFactList yields the dormant citizen
23
- * as a resumable `dead` PeerFact (no throw — all in-domain citizens probed).
24
- *
25
- * No IO, no backend, no API — readdir/probe are injected fakes.
26
- */
27
-
28
- import assert from "node:assert/strict";
29
- import * as path from "node:path";
30
- import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
31
- import type { MetaCitizenBackend, MetaIdentity } from "../pi-extensions/lib/meta-session.ts";
32
- import {
33
- controlSocketPath,
34
- inspectControlSocketPath,
35
- inspectTargetControlSocket,
36
- type LstatLike,
37
- mapInspectionToLiveness,
38
- SOCKET_SUFFIX,
39
- type SocketDirEntry,
40
- scanSocketProbes,
41
- } from "../pi-extensions/lib/socket-discovery.ts";
42
- import type { SocketLiveness } from "../pi-extensions/lib/socket-probe.ts";
43
-
44
- let passed = 0;
45
- function ok(label: string, cond: boolean): void {
46
- assert.ok(cond, label);
47
- console.log(` ok ${label}`);
48
- passed++;
49
- }
50
-
51
- const DIR = "/fake/entwurf-control";
52
- const GID_LIVE = "20260611T115213-3aa371"; // socket present → alive
53
- const GID_STALL = "20260611T135517-5f0d25"; // socket present → indeterminate (F3)
54
- const GID_DORMANT = "20260611T093858-14984d"; // pi citizen, no socket file → dead
55
- const GID_SOCKET_ONLY = "20260611T222222-bbbbbb"; // socket present, no citizen
56
-
57
- const PROBE_MAP: Record<string, SocketLiveness> = {
58
- [GID_LIVE]: "alive",
59
- [GID_STALL]: "indeterminate",
60
- [GID_SOCKET_ONLY]: "alive",
61
- // GID_DORMANT intentionally absent → fakeProbe returns "dead" (ENOENT)
62
- };
63
-
64
- // Plain regular-file entries (the common case). A name may be passed as a
65
- // `{name, isSymbolicLink}` tuple to mark it a symlink.
66
- function fakeReaddir(names: Array<string | SocketDirEntry>): (dir: string) => Promise<SocketDirEntry[]> {
67
- return async () => names.map((n) => (typeof n === "string" ? { name: n, isSymbolicLink: false } : n));
68
- }
69
-
70
- // Probe keyed by the gardenId embedded in the canonical socket path. An absent
71
- // gid → "dead", mirroring an ENOENT connect on a missing socket file.
72
- function fakeProbe(byGid: Record<string, SocketLiveness>): (socketPath: string) => Promise<SocketLiveness> {
73
- return async (socketPath: string) => {
74
- const base = path.basename(socketPath, SOCKET_SUFFIX);
75
- return byGid[base] ?? "dead";
76
- };
77
- }
78
-
79
- const NAMES = [`${GID_LIVE}.sock`, `${GID_STALL}.sock`, `${GID_SOCKET_ONLY}.sock`, "README.txt", "not-a-gid.sock"];
80
-
81
- async function main(): Promise<void> {
82
- // ── union: dir sockets ∪ pi citizens, each probed ──────────────────────────
83
- {
84
- const { probes } = await scanSocketProbes([GID_DORMANT, GID_LIVE], {
85
- dir: DIR,
86
- readdir: fakeReaddir(NAMES),
87
- probe: fakeProbe(PROBE_MAP),
88
- });
89
- const byGid = Object.fromEntries(probes.map((p) => [p.gardenId, p]));
90
- ok("union: dir sockets + pi citizens all probed (LIVE/STALL/SOCKET_ONLY/DORMANT)", probes.length === 4);
91
- ok("alive socket → alive", byGid[GID_LIVE]?.liveness === "alive");
92
- ok("F3: stalled socket → indeterminate (never folded to dead)", byGid[GID_STALL]?.liveness === "indeterminate");
93
- ok("dormant pi citizen (no socket file) → dead (ENOENT)", byGid[GID_DORMANT]?.liveness === "dead");
94
- ok("record-less socket → alive", byGid[GID_SOCKET_ONLY]?.liveness === "alive");
95
- }
96
-
97
- // ── #50 C4: a probe is liveness ONLY — the get_info enrich is gone ──────────
98
- // The per-socket RPC enrich (cwd/model/idle/infoError) existed to decorate the
99
- // retired socket-only quasi-citizen listing. A probe carrying anything beyond
100
- // gid + liveness would re-open that door, so the keyset is pinned exact.
101
- {
102
- const { probes } = await scanSocketProbes([], {
103
- dir: DIR,
104
- readdir: fakeReaddir([`${GID_LIVE}.sock`]),
105
- probe: fakeProbe(PROBE_MAP),
106
- });
107
- const keys = Object.keys(probes[0] as object).sort();
108
- assert.deepStrictEqual(keys, ["gardenId", "liveness"], `SocketProbe keyset drift: ${keys.join(",")}`);
109
- ok("C4: SocketProbe keyset exact (gardenId + liveness — no get_info enrich)", true);
110
- }
111
-
112
- // ── dir hygiene: non-.sock ignored; malformed names VISIBLY dropped (P3) ────
113
- {
114
- const { probes, malformedNames } = await scanSocketProbes([], {
115
- dir: DIR,
116
- readdir: fakeReaddir(NAMES),
117
- probe: fakeProbe(PROBE_MAP),
118
- });
119
- const gids = probes.map((p) => p.gardenId);
120
- ok("non-.sock entry ignored (README.txt)", !gids.includes("README"));
121
- ok("malformed socket name (not a garden id) not probed", !gids.some((g) => g.includes("not-a-gid")));
122
- ok(
123
- "only well-formed garden ids surface",
124
- gids.every((g) => /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(g)),
125
- );
126
- ok("P3: malformed .sock name surfaced (not silently dropped)", malformedNames.includes("not-a-gid.sock"));
127
- ok("P3: non-.sock entry is NOT a malformed-name diagnostic", !malformedNames.includes("README.txt"));
128
- }
129
-
130
- // ── symlink guard (P1): gid-shaped .sock symlink never probed, surfaced ─────
131
- {
132
- // GID_LIVE is a symlink (forgery vector) AND a pi citizen; GID_SOCKET_ONLY is
133
- // a symlink with no citizen (record-less). GID_DORMANT is a clean citizen.
134
- const { probes, symlinkedGardenIds } = await scanSocketProbes([GID_LIVE, GID_DORMANT], {
135
- dir: DIR,
136
- readdir: fakeReaddir([
137
- { name: `${GID_LIVE}.sock`, isSymbolicLink: true },
138
- { name: `${GID_SOCKET_ONLY}.sock`, isSymbolicLink: true },
139
- `${GID_DORMANT}.sock`,
140
- ]),
141
- // probe would return "alive" for both — proving we did NOT probe the symlinks.
142
- probe: fakeProbe({ [GID_LIVE]: "alive", [GID_SOCKET_ONLY]: "alive", [GID_DORMANT]: "alive" }),
143
- });
144
- const byGid = Object.fromEntries(probes.map((p) => [p.gardenId, p]));
145
- ok("P1: symlinked sockets surfaced in symlinkedGardenIds", symlinkedGardenIds.length === 2);
146
- ok("P1: symlinked citizen forced dead (NOT probed alive)", byGid[GID_LIVE]?.liveness === "dead");
147
- ok("P1: record-less symlink dropped from probes entirely", byGid[GID_SOCKET_ONLY] === undefined);
148
- ok("P1: clean citizen still probed normally", byGid[GID_DORMANT]?.liveness === "alive");
149
- ok(
150
- "P1: symlinkedGardenIds sorted",
151
- symlinkedGardenIds[0] !== undefined && symlinkedGardenIds[0] < (symlinkedGardenIds[1] ?? "~"),
152
- );
153
- }
154
-
155
- // ── dedup: gid in BOTH dir and citizen list → probed once ──────────────────
156
- {
157
- const { probes } = await scanSocketProbes([GID_LIVE], {
158
- dir: DIR,
159
- readdir: fakeReaddir([`${GID_LIVE}.sock`]),
160
- probe: fakeProbe(PROBE_MAP),
161
- });
162
- ok("dedup: gid in dir AND citizen list → one probe", probes.filter((p) => p.gardenId === GID_LIVE).length === 1);
163
- }
164
-
165
- // ── dir-read error (P2e②): ENOENT empty vs other-error surfaced ────────────
166
- {
167
- const enoent = await scanSocketProbes([GID_DORMANT], {
168
- dir: DIR,
169
- readdir: async () => {
170
- const e = new Error("ENOENT: no such directory") as NodeJS.ErrnoException;
171
- e.code = "ENOENT";
172
- throw e;
173
- },
174
- probe: fakeProbe(PROBE_MAP),
175
- });
176
- ok("missing dir (ENOENT) → empty listing, citizen still probed", enoent.probes.length === 1);
177
- ok("missing dir → dormant citizen reads dead", enoent.probes[0]?.liveness === "dead");
178
- ok("P2e②: ENOENT is the normal empty → dirError null (not surfaced)", enoent.dirError === null);
179
-
180
- // probe returns "alive" — proving the citizen is NOT probed when the dir is
181
- // untrusted (a non-ENOENT readdir failure): connect() would follow a symlink,
182
- // so we hold the citizen at indeterminate instead (GPi Q2/P1).
183
- let probeCalled = false;
184
- const eacces = await scanSocketProbes([GID_DORMANT], {
185
- dir: DIR,
186
- readdir: async () => {
187
- const e = new Error("EACCES: permission denied, scandir") as NodeJS.ErrnoException;
188
- e.code = "EACCES";
189
- throw e;
190
- },
191
- probe: async () => {
192
- probeCalled = true;
193
- return "alive";
194
- },
195
- });
196
- ok("P2e②: non-ENOENT readdir failure → dirError surfaced", typeof eacces.dirError === "string");
197
- ok("P2e②: EACCES message preserved in dirError", eacces.dirError?.includes("EACCES") === true);
198
- ok("P2e②: citizen still reported (socket axis lost, not blinded)", eacces.probes.length === 1);
199
- ok("P2e②/P1: untrusted dir → citizen NOT probed (no connect through unverified path)", !probeCalled);
200
- ok(
201
- "P2e②/P1: untrusted dir → citizen held at indeterminate (not alive, not stranded)",
202
- eacces.probes[0]?.liveness === "indeterminate",
203
- );
204
- }
205
-
206
- // ── determinism: sorted by gardenId ────────────────────────────────────────
207
- {
208
- const { probes } = await scanSocketProbes(["20260611T333333-cccccc", "20260611T111111-aaaaaa"], {
209
- dir: DIR,
210
- readdir: async () => [],
211
- probe: async () => "dead",
212
- });
213
- ok("determinism: probes sorted by gardenId", probes[0]?.gardenId === "20260611T111111-aaaaaa");
214
- }
215
-
216
- // ── canonical socket path shape ────────────────────────────────────────────
217
- ok("controlSocketPath = <dir>/<gid>.sock", controlSocketPath(GID_LIVE, DIR) === `${DIR}/${GID_LIVE}.sock`);
218
-
219
- // ── end-to-end: scanSocketProbes → resolveFactList (dormant resumable) ──────
220
- {
221
- const identity = (gid: string, backend: MetaCitizenBackend): MetaIdentity => ({
222
- schemaVersion: 3,
223
- gardenId: gid,
224
- backend,
225
- nativeSessionId: "n",
226
- cwd: "/x",
227
- model: null,
228
- transcriptPath: null,
229
- createdAt: "2026-06-11T00:00:00.000Z",
230
- recordUpdatedAt: "2026-06-11T00:00:00.000Z",
231
- });
232
- const citizens = [identity(GID_LIVE, "pi"), identity(GID_DORMANT, "pi")];
233
- const { probes } = await scanSocketProbes([GID_LIVE, GID_DORMANT], {
234
- dir: DIR,
235
- readdir: fakeReaddir([`${GID_LIVE}.sock`]),
236
- probe: fakeProbe(PROBE_MAP),
237
- });
238
- const out = resolveFactList(citizens, probes);
239
- ok("e2e: 2 pi citizens → 2 PeerFacts (no throw — all probed)", out.peers.length === 2);
240
- ok("e2e: live citizen alive", out.peers.find((p) => p.gardenId === GID_LIVE)?.liveness === "alive");
241
- ok(
242
- "e2e: dormant citizen dead (resumable, not stranded)",
243
- out.peers.find((p) => p.gardenId === GID_DORMANT)?.liveness === "dead",
244
- );
245
- ok("e2e: dir held only LIVE socket → no record-less fact", out.recordLessSockets.length === 0);
246
- }
247
-
248
- // ── inspectTargetControlSocket (?2 — lstat-then-connect, v2 decider helper) ──
249
- // The single-target, lock-time inspection. lstat is injected so every branch is
250
- // driven without a real fs; every variant carries the canonical socketPath.
251
- {
252
- const DIR2 = "/fake/ctl";
253
- const expectedPath = controlSocketPath(GID_LIVE, DIR2);
254
- const stat = (over: Partial<Record<"sym" | "sock", boolean>>): LstatLike => ({
255
- isSymbolicLink: () => over.sym === true,
256
- isSocket: () => over.sock === true,
257
- });
258
- const lstatThrowing = (code: string) => async (): Promise<LstatLike> => {
259
- const e = new Error(`${code}: lstat`) as NodeJS.ErrnoException;
260
- e.code = code;
261
- throw e;
262
- };
263
-
264
- // ENOENT → absent (the citizen is dormant; this is the path a resume creates).
265
- const absent = await inspectTargetControlSocket(GID_LIVE, DIR2, lstatThrowing("ENOENT"));
266
- ok("inspect: ENOENT → absent", absent.kind === "absent");
267
- ok("inspect: absent carries socketPath", absent.socketPath === expectedPath);
268
-
269
- // symlink → address-conflict (NEVER connected — P1), reason symlink.
270
- const sym = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sym: true }));
271
- ok("inspect: symlink → address-conflict", sym.kind === "address-conflict");
272
- ok("inspect: symlink reason", sym.kind === "address-conflict" && sym.reason === "symlink");
273
-
274
- // a real socket file → socket-file (safe to connect now).
275
- const sock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sock: true }));
276
- ok("inspect: socket file → socket-file", sock.kind === "socket-file");
277
- ok("inspect: socket-file carries socketPath", sock.socketPath === expectedPath);
278
-
279
- // a non-socket regular file/dir at the canonical path → address-conflict.
280
- const notSock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({}));
281
- ok("inspect: non-socket → address-conflict", notSock.kind === "address-conflict");
282
- ok("inspect: non-socket reason", notSock.kind === "address-conflict" && notSock.reason === "not-socket");
283
-
284
- // EACCES / unknown lstat error → indeterminate (never connect, never spawn).
285
- const indet = await inspectTargetControlSocket(GID_LIVE, DIR2, lstatThrowing("EACCES"));
286
- ok("inspect: EACCES → indeterminate", indet.kind === "indeterminate");
287
- ok("inspect: indeterminate carries error code", indet.kind === "indeterminate" && indet.error === "EACCES");
288
- // symlink is decided BEFORE socket — a symlink that also reports isSocket is still a conflict.
289
- const symSock = await inspectTargetControlSocket(GID_LIVE, DIR2, async () => stat({ sym: true, sock: true }));
290
- ok("inspect: symlink wins over isSocket (never connect a symlink)", symSock.kind === "address-conflict");
291
- }
292
-
293
- // ── inspectControlSocketPath (5c-3c R1 — the path-addressed SSOT) ────────────────
294
- // A resume watcher observes an expected socket path and MUST inspect THAT exact
295
- // path (no gid re-derivation). Proves the path-taking core classifies every branch
296
- // identically to the gid wrapper, and that the wrapper just feeds it controlSocketPath(gid).
297
- {
298
- const EXACT = "/fake/ctl/some-exact.sock";
299
- const WRAP_DIR = "/fake/ctl";
300
- const stat = (over: Partial<Record<"sym" | "sock", boolean>>): LstatLike => ({
301
- isSymbolicLink: () => over.sym === true,
302
- isSocket: () => over.sock === true,
303
- });
304
- const lstatThrowing = (code: string) => async (): Promise<LstatLike> => {
305
- const e = new Error(`${code}: lstat`) as NodeJS.ErrnoException;
306
- e.code = code;
307
- throw e;
308
- };
309
-
310
- // Inspects the EXACT path handed in (carried verbatim on every variant).
311
- const absent = await inspectControlSocketPath(EXACT, lstatThrowing("ENOENT"));
312
- ok("path-inspect: ENOENT → absent", absent.kind === "absent");
313
- ok("path-inspect: absent carries the EXACT path (no re-derivation)", absent.socketPath === EXACT);
314
-
315
- const sym = await inspectControlSocketPath(EXACT, async () => stat({ sym: true }));
316
- ok("path-inspect: symlink → address-conflict (never connected, P1)", sym.kind === "address-conflict");
317
- ok("path-inspect: symlink reason", sym.kind === "address-conflict" && sym.reason === "symlink");
318
-
319
- const sock = await inspectControlSocketPath(EXACT, async () => stat({ sock: true }));
320
- ok("path-inspect: socket file → socket-file", sock.kind === "socket-file");
321
- ok("path-inspect: socket-file carries the EXACT path", sock.socketPath === EXACT);
322
-
323
- const notSock = await inspectControlSocketPath(EXACT, async () => stat({}));
324
- ok("path-inspect: non-socket → address-conflict", notSock.kind === "address-conflict");
325
- ok("path-inspect: non-socket reason", notSock.kind === "address-conflict" && notSock.reason === "not-socket");
326
-
327
- const indet = await inspectControlSocketPath(EXACT, lstatThrowing("EACCES"));
328
- ok("path-inspect: EACCES → indeterminate", indet.kind === "indeterminate");
329
- ok("path-inspect: indeterminate carries error code", indet.kind === "indeterminate" && indet.error === "EACCES");
330
-
331
- // The gid wrapper feeds the path-core controlSocketPath(gid, dir) verbatim — same
332
- // classification, just a derived path. Proves the thin-wrapper equivalence.
333
- const wrapped = await inspectTargetControlSocket(GID_LIVE, WRAP_DIR, async () => stat({ sock: true }));
334
- const direct = await inspectControlSocketPath(controlSocketPath(GID_LIVE, WRAP_DIR), async () =>
335
- stat({ sock: true }),
336
- );
337
- ok(
338
- "path-inspect: gid wrapper === path-core over controlSocketPath(gid)",
339
- wrapped.socketPath === direct.socketPath && wrapped.kind === direct.kind,
340
- );
341
- }
342
-
343
- // mapInspectionToLiveness — the shared inspection→liveness mapper (SSOT for the 5b
344
- // decider AND the 5c-2b dead-control-send resolver). Proves the 4-way mapping that
345
- // both callers depend on, so a drift between them is impossible: absent→dead,
346
- // socket-file→(probe result), indeterminate→indeterminate, address-conflict→conflict.
347
- {
348
- const SP = "/fake/ctl/m.sock";
349
- const absent = await mapInspectionToLiveness({ kind: "absent", socketPath: SP }, async () => "alive");
350
- ok(
351
- "map: absent → dead (ENOENT = honest dormant), carries socketPath",
352
- !("addressConflict" in absent) && absent.liveness === "dead" && absent.socketPath === SP,
353
- );
354
-
355
- let probed = "";
356
- const live = await mapInspectionToLiveness({ kind: "socket-file", socketPath: SP }, async (p) => {
357
- probed = p;
358
- return "alive";
359
- });
360
- ok(
361
- "map: socket-file → probe result (the ONLY case that connects)",
362
- !("addressConflict" in live) && live.liveness === "alive" && probed === SP,
363
- );
364
-
365
- const stall = await mapInspectionToLiveness({ kind: "socket-file", socketPath: SP }, async () => "indeterminate");
366
- ok(
367
- "map: socket-file + probe indeterminate → indeterminate (never folded to dead)",
368
- !("addressConflict" in stall) && stall.liveness === "indeterminate",
369
- );
370
-
371
- let connectedOnIndet = false;
372
- const indet = await mapInspectionToLiveness(
373
- { kind: "indeterminate", socketPath: SP, error: "EACCES" },
374
- async () => {
375
- connectedOnIndet = true;
376
- return "alive";
377
- },
378
- );
379
- ok(
380
- "map: inspect indeterminate → indeterminate, NEVER connects",
381
- !("addressConflict" in indet) && indet.liveness === "indeterminate" && connectedOnIndet === false,
382
- );
383
-
384
- let connectedOnConflict = false;
385
- const conflict = await mapInspectionToLiveness(
386
- { kind: "address-conflict", socketPath: SP, reason: "symlink" },
387
- async () => {
388
- connectedOnConflict = true;
389
- return "alive";
390
- },
391
- );
392
- ok(
393
- "map: address-conflict → {addressConflict:true}, NEVER connects",
394
- "addressConflict" in conflict && connectedOnConflict === false,
395
- );
396
- }
397
-
398
- console.log(`\n[check-socket-discovery] ${passed} assertions ok`);
399
- }
400
-
401
- await main();