@junghanacs/entwurf 0.12.8 → 0.12.9
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 -4
- package/CHANGELOG.md +20 -1
- package/DELIVERY.md +1 -1
- package/README.md +122 -10
- package/demo/README.md +2 -2
- package/docs/setup-clean-host.md +14 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +9 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +12 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +8 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +15 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +483 -34
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +8 -3
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +14 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +155 -29
- package/mcp/entwurf-bridge/src/index.ts +10 -12
- package/package.json +7 -7
- package/pi-extensions/entwurf-control.ts +12 -12
- package/pi-extensions/lib/acp/models.ts +12 -12
- package/pi-extensions/lib/entwurf-fact-provider.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +8 -2
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +8 -2
- package/pi-extensions/lib/meta-sender-identity.ts +15 -5
- package/pi-extensions/lib/meta-session.ts +526 -38
- package/pi-extensions/meta-bridge-hook.ts +8 -2
- package/run.sh +30 -26
- package/scripts/agy-imprint.ts +15 -1
- package/scripts/check-acp-session-reuse.ts +1 -1
- package/scripts/check-acp-session-store.ts +3 -3
- package/scripts/check-agy-sender-identity.ts +83 -0
- package/scripts/check-entwurf-control-rpc.ts +2 -2
- package/scripts/check-entwurf-fact-provider.ts +9 -1
- package/scripts/check-entwurf-facts.ts +1 -1
- package/scripts/check-entwurf-mailbox-guard.ts +6 -2
- package/scripts/check-entwurf-resume-args.ts +6 -6
- package/scripts/check-entwurf-session-identity.ts +7 -6
- package/scripts/check-entwurf-v2-production.ts +4 -2
- package/scripts/check-entwurf-v2-spawn-production.ts +2 -2
- package/scripts/check-fresh-cut-gate.sh +305 -4
- package/scripts/check-meta-identity-consumers.ts +501 -1
- package/scripts/check-meta-listing.ts +91 -9
- package/scripts/check-meta-receiver-marker.ts +54 -0
- package/scripts/check-model-lock.ts +1 -1
- package/scripts/meta-bridge-fresh-cut.ts +164 -28
- package/scripts/pi_settings_io.py +65 -0
- package/scripts/register-pi-package.py +183 -37
- package/scripts/register-pi-provider.py +68 -10
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +1 -1
- package/scripts/smoke-pi-attach.ts +7 -2
- package/scripts/smoke-user-scope-citizen.sh +177 -0
|
@@ -19,9 +19,16 @@
|
|
|
19
19
|
* - birthPiCitizen REFUSES on every one of those defects, naming fresh-cut,
|
|
20
20
|
* BEFORE any write — including defects that have nothing to do with the
|
|
21
21
|
* session being born.
|
|
22
|
+
* - readAddressableMetaIdentity (#52) — the read a DISPATCH does: the targeted
|
|
23
|
+
* contract PLUS store-wide `nativeSessionId` uniqueness. The two reads are
|
|
24
|
+
* deliberately different functions, so this gate pins BOTH halves of that
|
|
25
|
+
* split: the addressable read refuses a duplicate, and the plain targeted read
|
|
26
|
+
* still does NOT scan (the README promise that a call-relay does not re-scan
|
|
27
|
+
* the store per message is a contract, not an oversight).
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import assert from "node:assert/strict";
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
25
32
|
import fs from "node:fs";
|
|
26
33
|
import os from "node:os";
|
|
27
34
|
import path from "node:path";
|
|
@@ -29,11 +36,18 @@ import {
|
|
|
29
36
|
type ActiveStoreEntry,
|
|
30
37
|
certifyActiveStore,
|
|
31
38
|
certifyActiveStoreDir,
|
|
39
|
+
classifyRecordReadFailure,
|
|
32
40
|
FRESH_CUT_COMMAND,
|
|
33
41
|
listAllMetaIdentities,
|
|
34
42
|
type MetaIdentity,
|
|
35
43
|
MetaRecordError,
|
|
44
|
+
makeStoreRecordReader,
|
|
36
45
|
metaRecordExistsByGardenId,
|
|
46
|
+
NOT_REGULAR_ENTRY_CODE,
|
|
47
|
+
nativeSessionIdRivals,
|
|
48
|
+
type RecordReadFailure,
|
|
49
|
+
readActiveStoreEntries,
|
|
50
|
+
readAddressableMetaIdentity,
|
|
37
51
|
readMetaIdentityByGardenId,
|
|
38
52
|
serializeMetaIdentity,
|
|
39
53
|
} from "../pi-extensions/lib/meta-session.ts";
|
|
@@ -183,6 +197,492 @@ try {
|
|
|
183
197
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
184
198
|
}
|
|
185
199
|
|
|
200
|
+
// --- #52: the ADDRESSABLE read holds the store-wide uniqueness rule ----------
|
|
201
|
+
// A duplicate `nativeSessionId` needs no corruption to appear: `upsertMetaSession`
|
|
202
|
+
// certifies and then writes, which is not a transaction, so two concurrent births can
|
|
203
|
+
// both read one clean snapshot and mint different garden ids for one native session.
|
|
204
|
+
// Dispatching at either id then reaches the SAME conversation/transcript twice.
|
|
205
|
+
withStore(
|
|
206
|
+
(dir) => {
|
|
207
|
+
record(dir, GID_A, "native-shared");
|
|
208
|
+
record(dir, GID_B, "native-shared");
|
|
209
|
+
record(dir, GID_C, "native-c");
|
|
210
|
+
},
|
|
211
|
+
(dir) => {
|
|
212
|
+
throwsNaming(
|
|
213
|
+
"the addressable read REFUSES a garden id that does not hold its nativeSessionId alone",
|
|
214
|
+
() => readAddressableMetaIdentity(GID_A, dir),
|
|
215
|
+
"must be unique",
|
|
216
|
+
);
|
|
217
|
+
throwsNaming(
|
|
218
|
+
"…from EITHER side of the pair (neither rival is silently preferred)",
|
|
219
|
+
() => readAddressableMetaIdentity(GID_B, dir),
|
|
220
|
+
"must be unique",
|
|
221
|
+
);
|
|
222
|
+
throwsNaming(
|
|
223
|
+
"the duplicate refusal names the fresh-cut command, like every other store defect",
|
|
224
|
+
() => readAddressableMetaIdentity(GID_A, dir),
|
|
225
|
+
FRESH_CUT_COMMAND,
|
|
226
|
+
);
|
|
227
|
+
ok(
|
|
228
|
+
"the refusal NAMES the rival file, so the operator can see both records",
|
|
229
|
+
(() => {
|
|
230
|
+
try {
|
|
231
|
+
readAddressableMetaIdentity(GID_A, dir);
|
|
232
|
+
return false;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return err instanceof MetaRecordError && err.message.includes(`${GID_B}.meta.json`);
|
|
235
|
+
}
|
|
236
|
+
})(),
|
|
237
|
+
);
|
|
238
|
+
ok(
|
|
239
|
+
"an unrelated citizen in the SAME store still reads addressably (no store-wide contagion)",
|
|
240
|
+
readAddressableMetaIdentity(GID_C, dir).nativeSessionId === "native-c",
|
|
241
|
+
);
|
|
242
|
+
// The other half of the split, stated as a REQUIREMENT rather than a leftover: the
|
|
243
|
+
// plain targeted read is what the mailbox poke / sender-marker trust / entwurf_self
|
|
244
|
+
// use, and turning it store-wide is the design README explicitly does not have.
|
|
245
|
+
ok(
|
|
246
|
+
"the plain targeted read still answers on a duplicated store (it does NOT scan — README scope)",
|
|
247
|
+
readMetaIdentityByGardenId(GID_A, dir).gardenId === GID_A,
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// --- #52: the rival scan itself (pure) — a RIVAL is an ADDRESSABLE record -----
|
|
253
|
+
// The blind spots are claims about REACHABILITY, and each has to be pinned separately
|
|
254
|
+
// from "the scan works", because getting any of them wrong quarantines a healthy
|
|
255
|
+
// citizen. GPT's cross-review found two of them open in the first cut of this: a
|
|
256
|
+
// symlinked neighbour was FOLLOWED and counted, and a drifted one was counted too.
|
|
257
|
+
{
|
|
258
|
+
const own = identity(GID_A, "native-shared");
|
|
259
|
+
const map: Record<string, string> = {
|
|
260
|
+
[`${GID_A}.meta.json`]: serializeMetaIdentity(own),
|
|
261
|
+
[`${GID_B}.meta.json`]: serializeMetaIdentity(identity(GID_B, "native-shared")),
|
|
262
|
+
[`${GID_C}.meta.json`]: serializeMetaIdentity(identity(GID_C, "native-c")),
|
|
263
|
+
"malformed.meta.json": "{nope\n",
|
|
264
|
+
"not-a-record.txt": "ignore me\n",
|
|
265
|
+
};
|
|
266
|
+
const touched: string[] = [];
|
|
267
|
+
const read = (f: string): string => {
|
|
268
|
+
touched.push(f);
|
|
269
|
+
const v = map[f];
|
|
270
|
+
if (v === undefined) throw new Error(`ENOENT: ${f}`);
|
|
271
|
+
return v;
|
|
272
|
+
};
|
|
273
|
+
const all = (irregular: readonly string[] = []): ActiveStoreEntry[] =>
|
|
274
|
+
Object.keys(map).map((filename) => ({ filename, regularFile: !irregular.includes(filename) }));
|
|
275
|
+
|
|
276
|
+
const rivals = nativeSessionIdRivals(own, all(), read);
|
|
277
|
+
ok("rival scan finds the other holder", rivals.length === 1 && rivals[0] === `${GID_B}.meta.json`);
|
|
278
|
+
ok("the record's OWN file is never its own rival", !rivals.includes(`${GID_A}.meta.json`));
|
|
279
|
+
ok(
|
|
280
|
+
"an unparseable neighbour is skipped, not thrown on (one corrupt file must not break addressing)",
|
|
281
|
+
nativeSessionIdRivals(identity(GID_C, "native-c"), all(), read).length === 0,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// Rule 1 at the rival scan: a non-regular entry is not a candidate AND its bytes are
|
|
285
|
+
// never touched. Reading it "just to check" would break the rule in the act of
|
|
286
|
+
// enforcing it — the callback log is what makes that assertion real rather than
|
|
287
|
+
// inferred from the verdict.
|
|
288
|
+
touched.length = 0;
|
|
289
|
+
const withSymlink = nativeSessionIdRivals(own, all([`${GID_B}.meta.json`]), read);
|
|
290
|
+
ok("a NON-REGULAR neighbour is not a rival (symlinked bytes cannot claim an address)", withSymlink.length === 0);
|
|
291
|
+
ok(
|
|
292
|
+
"…and its bytes were NEVER read — the reader was not called for it (rule 1: never followed)",
|
|
293
|
+
!touched.includes(`${GID_B}.meta.json`),
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// A drifted neighbour is unreachable by garden-id lookup from either name, so it
|
|
297
|
+
// cannot compete for an address — the certification and the listing still call it a
|
|
298
|
+
// defect, but it must not blind a healthy citizen.
|
|
299
|
+
{
|
|
300
|
+
const driftMap: Record<string, string> = {
|
|
301
|
+
[`${GID_A}.meta.json`]: serializeMetaIdentity(own),
|
|
302
|
+
// filename B, body claims C, and C shares A's native id
|
|
303
|
+
[`${GID_B}.meta.json`]: serializeMetaIdentity(identity(GID_C, "native-shared")),
|
|
304
|
+
};
|
|
305
|
+
const driftRead = (f: string): string => driftMap[f] ?? "";
|
|
306
|
+
const driftEntries: ActiveStoreEntry[] = Object.keys(driftMap).map((filename) => ({
|
|
307
|
+
filename,
|
|
308
|
+
regularFile: true,
|
|
309
|
+
}));
|
|
310
|
+
ok(
|
|
311
|
+
"a DRIFTED neighbour is not a rival (no garden id can reach it, so it addresses nothing)",
|
|
312
|
+
nativeSessionIdRivals(own, driftEntries, driftRead).length === 0,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The other direction: a candidate we could not READ is an unanswered question, not
|
|
317
|
+
// a clean scan. Parse failure and read failure are different facts and must not
|
|
318
|
+
// share one catch.
|
|
319
|
+
{
|
|
320
|
+
const failRead = (f: string): string => {
|
|
321
|
+
if (f === `${GID_B}.meta.json`) {
|
|
322
|
+
const err = new Error("EACCES: permission denied") as Error & { code?: string };
|
|
323
|
+
err.code = "EACCES";
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
return map[f] ?? "";
|
|
327
|
+
};
|
|
328
|
+
let threw = false;
|
|
329
|
+
try {
|
|
330
|
+
nativeSessionIdRivals(own, all(), failRead);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
threw = err instanceof MetaRecordError && err.message.includes("unanswered question");
|
|
333
|
+
}
|
|
334
|
+
ok("an UNREADABLE regular candidate THROWS — it may be the duplicate (never skipped)", threw);
|
|
335
|
+
|
|
336
|
+
// ENOENT is the one exception, and the one this repo already recognises: a file
|
|
337
|
+
// that vanished between the readdir and the read is not in the store.
|
|
338
|
+
const goneRead = (f: string): string => {
|
|
339
|
+
if (f === `${GID_B}.meta.json`) {
|
|
340
|
+
const err = new Error("ENOENT: no such file") as Error & { code?: string };
|
|
341
|
+
err.code = "ENOENT";
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
return map[f] ?? "";
|
|
345
|
+
};
|
|
346
|
+
ok(
|
|
347
|
+
"a neighbour that RACED AWAY (ENOENT) is skipped — it is not in the store, so it holds nothing",
|
|
348
|
+
nativeSessionIdRivals(own, all(), goneRead).length === 0,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- #52: the same three shapes, driven through the REAL fs binding ----------
|
|
354
|
+
// The pure cells above pin the rule; these pin that the production binding hands it
|
|
355
|
+
// entries with the right kind. A pure-only proof would have passed while the fs path
|
|
356
|
+
// still did a bare-name readdir and followed symlinks — which is exactly what shipped.
|
|
357
|
+
{
|
|
358
|
+
const tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "psa-idcons-addr-"));
|
|
359
|
+
try {
|
|
360
|
+
// A symlink whose target lives OUTSIDE the store and holds the same native id.
|
|
361
|
+
const dir = path.join(tmp, "symlink-store");
|
|
362
|
+
const outside = path.join(tmp, "outside");
|
|
363
|
+
fs.mkdirSync(dir);
|
|
364
|
+
fs.mkdirSync(outside);
|
|
365
|
+
record(dir, GID_A, "native-shared");
|
|
366
|
+
const foreign = path.join(outside, "foreign.json");
|
|
367
|
+
fs.writeFileSync(foreign, serializeMetaIdentity(identity(GID_B, "native-shared")));
|
|
368
|
+
fs.symlinkSync(foreign, path.join(dir, `${GID_B}.meta.json`));
|
|
369
|
+
ok(
|
|
370
|
+
"fs: a SYMLINKED duplicate does not quarantine the healthy regular record",
|
|
371
|
+
readAddressableMetaIdentity(GID_A, dir).gardenId === GID_A,
|
|
372
|
+
);
|
|
373
|
+
ok(
|
|
374
|
+
"fs: the listing reports that symlink as a diagnostic and lists the healthy citizen",
|
|
375
|
+
(() => {
|
|
376
|
+
const listed = listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir));
|
|
377
|
+
return (
|
|
378
|
+
listed.identities.length === 1 &&
|
|
379
|
+
listed.identities[0]?.gardenId === GID_A &&
|
|
380
|
+
listed.errors.length === 1 &&
|
|
381
|
+
listed.errors[0]?.filename === `${GID_B}.meta.json` &&
|
|
382
|
+
listed.errors[0]?.message.includes("not a regular file")
|
|
383
|
+
);
|
|
384
|
+
})(),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// A drifted duplicate through the real binding.
|
|
388
|
+
const driftDir = path.join(tmp, "drift-store");
|
|
389
|
+
fs.mkdirSync(driftDir);
|
|
390
|
+
record(driftDir, GID_A, "native-shared");
|
|
391
|
+
fs.writeFileSync(
|
|
392
|
+
path.join(driftDir, `${GID_B}.meta.json`),
|
|
393
|
+
serializeMetaIdentity(identity(GID_C, "native-shared")),
|
|
394
|
+
);
|
|
395
|
+
ok(
|
|
396
|
+
"fs: a DRIFTED duplicate does not quarantine the healthy record either",
|
|
397
|
+
readAddressableMetaIdentity(GID_A, driftDir).gardenId === GID_A,
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
401
|
+
// A regular, valid, genuine duplicate this process cannot read.
|
|
402
|
+
const blindDir = path.join(tmp, "unreadable-rival");
|
|
403
|
+
fs.mkdirSync(blindDir);
|
|
404
|
+
record(blindDir, GID_A, "native-shared");
|
|
405
|
+
const rival = path.join(blindDir, `${GID_B}.meta.json`);
|
|
406
|
+
fs.writeFileSync(rival, serializeMetaIdentity(identity(GID_B, "native-shared")));
|
|
407
|
+
fs.chmodSync(rival, 0o000);
|
|
408
|
+
try {
|
|
409
|
+
throwsNaming(
|
|
410
|
+
"fs: an UNREADABLE rival makes the addressable read REFUSE (it may be the duplicate)",
|
|
411
|
+
() => readAddressableMetaIdentity(GID_A, blindDir),
|
|
412
|
+
"unanswered question",
|
|
413
|
+
);
|
|
414
|
+
} finally {
|
|
415
|
+
fs.chmodSync(rival, 0o600);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The store-level vacuity guard: if the readdir itself fails, the question was
|
|
419
|
+
// never asked, and answering it anyway is the same fail-open shape.
|
|
420
|
+
const unlistable = path.join(tmp, "store");
|
|
421
|
+
fs.mkdirSync(unlistable);
|
|
422
|
+
record(unlistable, GID_A, "native-a");
|
|
423
|
+
fs.chmodSync(unlistable, 0o100); // --x: the targeted read still works, readdir does not
|
|
424
|
+
try {
|
|
425
|
+
throwsNaming(
|
|
426
|
+
"fs: an unlistable store makes the addressable read REFUSE, never answer 'unique'",
|
|
427
|
+
() => readAddressableMetaIdentity(GID_A, unlistable),
|
|
428
|
+
"failure to inspect the store",
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
fs.chmodSync(unlistable, 0o700);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// --- #52: rule 1 survives the RACE, not just the settled store ---------------
|
|
440
|
+
// The cells above hand the reader an entry that was already a symlink when the scan
|
|
441
|
+
// classified it. The defect this pins is the other order: the entry is a REGULAR FILE at
|
|
442
|
+
// classification time and a symlink by the time the bytes are read. `lstat`-then-
|
|
443
|
+
// `readFileSync(path)` classifies one entry and reads another, so it followed the swap
|
|
444
|
+
// into foreign bytes while every "is it a symlink" test on a settled store stayed green.
|
|
445
|
+
// A plain already-a-symlink read cannot fail that way, so it cannot prove this.
|
|
446
|
+
{
|
|
447
|
+
const tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "psa-idcons-toctou-"));
|
|
448
|
+
try {
|
|
449
|
+
const dir = path.join(tmp, "store");
|
|
450
|
+
const outside = path.join(tmp, "outside");
|
|
451
|
+
fs.mkdirSync(dir);
|
|
452
|
+
fs.mkdirSync(outside);
|
|
453
|
+
const FOREIGN = serializeMetaIdentity(identity(GID_B, "native-foreign"));
|
|
454
|
+
const foreign = path.join(outside, "foreign.json");
|
|
455
|
+
fs.writeFileSync(foreign, FOREIGN);
|
|
456
|
+
|
|
457
|
+
const filename = `${GID_B}.meta.json`;
|
|
458
|
+
const target = path.join(dir, filename);
|
|
459
|
+
record(dir, GID_B, "native-own");
|
|
460
|
+
|
|
461
|
+
// The snapshot the production scan takes — and it says REGULAR, which is the whole
|
|
462
|
+
// point: every kind-based guard downstream is now working from a true-but-stale fact.
|
|
463
|
+
const snapshot = readActiveStoreEntries(dir).find((e) => e.filename === filename);
|
|
464
|
+
ok("toctou: the entry is classified as a regular file BEFORE the swap", snapshot?.regularFile === true);
|
|
465
|
+
|
|
466
|
+
// …now the swap, in the window the old code left open.
|
|
467
|
+
fs.unlinkSync(target);
|
|
468
|
+
fs.symlinkSync(foreign, target);
|
|
469
|
+
|
|
470
|
+
let followed: string | null = null;
|
|
471
|
+
let raced: unknown;
|
|
472
|
+
try {
|
|
473
|
+
followed = makeStoreRecordReader(dir)(filename);
|
|
474
|
+
} catch (err) {
|
|
475
|
+
raced = err;
|
|
476
|
+
}
|
|
477
|
+
ok("toctou: the shared reader refuses the swapped entry instead of returning bytes", followed === null);
|
|
478
|
+
ok(
|
|
479
|
+
"toctou: not one byte of the FOREIGN file is read (the refusal is the open, not a later parse)",
|
|
480
|
+
followed !== FOREIGN,
|
|
481
|
+
);
|
|
482
|
+
ok(
|
|
483
|
+
"toctou: the refusal keeps its errno (ELOOP), so callers can still tell it from ENOENT",
|
|
484
|
+
(raced as { code?: unknown } | undefined)?.code === "ELOOP",
|
|
485
|
+
);
|
|
486
|
+
// LAYER 1 — the settled store, classified WITHOUT opening anything. These are not
|
|
487
|
+
// the race mapping (that is pinned purely, further down): the `lstat` policy answers
|
|
488
|
+
// first here, which is exactly what must keep happening. Deleting this layer in
|
|
489
|
+
// favour of the fd looked like removing a duplicate and regressed every shape whose
|
|
490
|
+
// `open` errno is not about regularity — a socket (ENXIO) and a mode-000 directory
|
|
491
|
+
// (EACCES) both became "inspection failure" instead of the certification's own
|
|
492
|
+
// sentence (2026-07-26 cross-review round 3, reproduced).
|
|
493
|
+
throwsNaming(
|
|
494
|
+
"settled: a symlinked record earns the not-a-regular-file refusal without being opened",
|
|
495
|
+
() => readMetaIdentityByGardenId(GID_B, dir),
|
|
496
|
+
"not a regular file",
|
|
497
|
+
);
|
|
498
|
+
throwsNaming(
|
|
499
|
+
"settled: …and that refusal names the fresh-cut verb",
|
|
500
|
+
() => readMetaIdentityByGardenId(GID_B, dir),
|
|
501
|
+
FRESH_CUT_COMMAND,
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// A fifo is why the open carries O_NONBLOCK: `open(fifo, O_RDONLY)` BLOCKS until a
|
|
505
|
+
// writer appears, so deciding the kind ON THE FD would hang every scan on a host
|
|
506
|
+
// where nobody is writing — a hazard classify-then-read did not have. The cell must
|
|
507
|
+
// therefore be able to FAIL on a regression, and a blocking call in this process can
|
|
508
|
+
// only hang: the gate would be killed by whatever timeout wraps it, which is not an
|
|
509
|
+
// assertion (verified: dropping the flag made this gate hang until SIGTERM, rc=124).
|
|
510
|
+
// So the read runs in a CHILD with a bounded timeout, and the timeout IS the failure.
|
|
511
|
+
{
|
|
512
|
+
const fifoDir = path.join(tmp, "fifo-store");
|
|
513
|
+
fs.mkdirSync(fifoDir);
|
|
514
|
+
const fifo = path.join(fifoDir, `${GID_A}.meta.json`);
|
|
515
|
+
// No silent skip: Linux is the only certified axis, so a host without `mkfifo`
|
|
516
|
+
// cannot quietly downgrade this proof — it fails here instead.
|
|
517
|
+
execFileSync("mkfifo", [fifo], { stdio: "ignore" });
|
|
518
|
+
const childSrc = path.join(tmp, "fifo-child.mjs");
|
|
519
|
+
const lib = new URL("../pi-extensions/lib/meta-session.ts", import.meta.url).pathname;
|
|
520
|
+
fs.writeFileSync(
|
|
521
|
+
childSrc,
|
|
522
|
+
`import { makeStoreRecordReader, NOT_REGULAR_ENTRY_CODE } from ${JSON.stringify(lib)};\n` +
|
|
523
|
+
`try {\n` +
|
|
524
|
+
` makeStoreRecordReader(${JSON.stringify(fifoDir)})(${JSON.stringify(`${GID_A}.meta.json`)});\n` +
|
|
525
|
+
` console.log("RETURNED-BYTES");\n` +
|
|
526
|
+
`} catch (err) {\n` +
|
|
527
|
+
` console.log(err?.code === NOT_REGULAR_ENTRY_CODE ? "REFUSED-NOT-REGULAR" : "OTHER:" + err?.code);\n` +
|
|
528
|
+
`}\n`,
|
|
529
|
+
);
|
|
530
|
+
let verdict: string;
|
|
531
|
+
try {
|
|
532
|
+
verdict = execFileSync(process.execPath, [childSrc], { timeout: 10_000, encoding: "utf8" }).trim();
|
|
533
|
+
} catch (err) {
|
|
534
|
+
const e = err as { code?: unknown; killed?: boolean };
|
|
535
|
+
const timedOut = e.code === "ETIMEDOUT" || e.killed === true;
|
|
536
|
+
verdict = timedOut ? "BLOCKED-ON-OPEN (the open never returned — O_NONBLOCK is gone)" : `CHILD-FAILED: ${err}`;
|
|
537
|
+
}
|
|
538
|
+
ok(
|
|
539
|
+
`a fifo is refused as not-a-regular-file and the open RETURNS (O_NONBLOCK) — child said ${verdict}`,
|
|
540
|
+
verdict === "REFUSED-NOT-REGULAR",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Every refusal above runs through a `finally closeSync`. A reader that leaks the
|
|
545
|
+
// description on the refusing paths would exhaust the process's fds on a store full
|
|
546
|
+
// of them, which is a slow death rather than a loud one — so it gets counted.
|
|
547
|
+
if (fs.existsSync("/proc/self/fd")) {
|
|
548
|
+
const openFds = (): number => fs.readdirSync("/proc/self/fd").length;
|
|
549
|
+
const goodDir = path.join(tmp, "good-store");
|
|
550
|
+
fs.mkdirSync(goodDir);
|
|
551
|
+
record(goodDir, GID_A, "native-a");
|
|
552
|
+
const read = makeStoreRecordReader(goodDir);
|
|
553
|
+
read(`${GID_A}.meta.json`); // warm any lazy fd the runtime opens on first read
|
|
554
|
+
const before = openFds();
|
|
555
|
+
for (let i = 0; i < 200; i++) {
|
|
556
|
+
read(`${GID_A}.meta.json`);
|
|
557
|
+
try {
|
|
558
|
+
makeStoreRecordReader(dir)(filename); // the ELOOP path
|
|
559
|
+
} catch {}
|
|
560
|
+
}
|
|
561
|
+
ok("the reader closes its fd on BOTH the returning and the refusing path", openFds() === before);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// LAYER 2 — the race mapping, pinned PURELY. With layer 1 in front of it, no settled
|
|
565
|
+
// store can reach these branches, so a runtime cell cannot hold them: the previous
|
|
566
|
+
// round's "targeted mapping" cells were vacuous for exactly that reason and passed
|
|
567
|
+
// with the mapping deleted. A pure errno classifier is the seam that IS decidable,
|
|
568
|
+
// and a synthetic errno takes the same branch a real one does.
|
|
569
|
+
{
|
|
570
|
+
const cases: [unknown, RecordReadFailure, string][] = [
|
|
571
|
+
["ENOENT", "absent", "a record that vanished mid-read is absence, and absence alone"],
|
|
572
|
+
["ELOOP", "irregular", "O_NOFOLLOW refusing a swapped-in symlink is the settled symlink verdict"],
|
|
573
|
+
["ENXIO", "irregular", "a socket swapped in mid-read says what lstat would have said, not 'unreadable'"],
|
|
574
|
+
[NOT_REGULAR_ENTRY_CODE, "irregular", "the fstat verdict (directory/fifo on the fd) is irregular too"],
|
|
575
|
+
["EACCES", "unreadable", "an unreadable entry stays LOUD — never laundered into a clean negative"],
|
|
576
|
+
["ENOTDIR", "unreadable", "a broken store shape is an inspection failure"],
|
|
577
|
+
[undefined, "unreadable", "an errno-less failure is unreadable, never absence"],
|
|
578
|
+
];
|
|
579
|
+
for (const [code, expected, why] of cases) {
|
|
580
|
+
ok(`race mapping: ${String(code)} → ${expected} — ${why}`, classifyRecordReadFailure(code) === expected);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// A UNIX SOCKET wearing a record's name — the shape that exposed the regression. It
|
|
585
|
+
// is pinned in BOTH layers: classified non-regular without being opened here, and
|
|
586
|
+
// (if it were swapped in mid-read) mapped from ENXIO to the same sentence above. What
|
|
587
|
+
// this cell holds is the CONTRACT — certification and the targeted read must say the
|
|
588
|
+
// same thing about one entry — not layer 1's existence: with ENXIO mapped, an
|
|
589
|
+
// fd-only build still answers correctly here. The cell that holds layer 1 is the
|
|
590
|
+
// mode-000 directory below, whose errno (EACCES) is genuinely about permission and
|
|
591
|
+
// so cannot stand in for a regularity verdict. Both are needed; neither replaces the
|
|
592
|
+
// other. (Verified by mutation: removing layer 1 leaves this cell green and reds
|
|
593
|
+
// that one.)
|
|
594
|
+
{
|
|
595
|
+
const sockStore = path.join(tmp, "socket-store");
|
|
596
|
+
fs.mkdirSync(sockStore);
|
|
597
|
+
const sockPath = path.join(sockStore, `${GID_A}.meta.json`);
|
|
598
|
+
const binder = path.join(tmp, "bind-socket.mjs");
|
|
599
|
+
fs.writeFileSync(
|
|
600
|
+
binder,
|
|
601
|
+
'import net from "node:net";\n' +
|
|
602
|
+
"const srv = net.createServer();\n" +
|
|
603
|
+
"srv.listen(process.argv[2], () => { srv.unref(); process.exit(0); });\n",
|
|
604
|
+
);
|
|
605
|
+
execFileSync(process.execPath, [binder, sockPath], { timeout: 10_000, stdio: "ignore" });
|
|
606
|
+
ok("socket-shaped record: the entry really is a socket on disk", fs.lstatSync(sockPath).isSocket());
|
|
607
|
+
throwsNaming(
|
|
608
|
+
"socket-shaped record: the TARGETED read calls it not-a-regular-file (not 'inspection failure')",
|
|
609
|
+
() => readMetaIdentityByGardenId(GID_A, sockStore),
|
|
610
|
+
"not a regular file",
|
|
611
|
+
);
|
|
612
|
+
throwsNaming(
|
|
613
|
+
"socket-shaped record: …naming the fresh-cut verb, like every other rule-1 defect",
|
|
614
|
+
() => readMetaIdentityByGardenId(GID_A, sockStore),
|
|
615
|
+
FRESH_CUT_COMMAND,
|
|
616
|
+
);
|
|
617
|
+
ok(
|
|
618
|
+
"socket-shaped record: certification says the same thing (one contract, both directions)",
|
|
619
|
+
certifyActiveStoreDir(sockStore).defects.some(
|
|
620
|
+
(d) => d.filename === `${GID_A}.meta.json` && d.message.includes("not a regular file"),
|
|
621
|
+
),
|
|
622
|
+
);
|
|
623
|
+
ok(
|
|
624
|
+
"socket-shaped record: the listing reports it as a diagnostic, never as a citizen",
|
|
625
|
+
(() => {
|
|
626
|
+
const listed = listAllMetaIdentities(readActiveStoreEntries(sockStore), makeStoreRecordReader(sockStore));
|
|
627
|
+
return listed.identities.length === 0 && (listed.errors[0]?.message ?? "").includes("not a regular file");
|
|
628
|
+
})(),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// The contract BOUNDARY, stated rather than assumed: a mode-000 directory is refused
|
|
633
|
+
// as non-regular by the classification, and its EACCES-on-open is never consulted.
|
|
634
|
+
// (If layer 1 were removed again, this cell reads "inspection failure" and goes red.)
|
|
635
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
636
|
+
const specialStore = path.join(tmp, "mode000-dir-store");
|
|
637
|
+
const entry = path.join(specialStore, `${GID_A}.meta.json`);
|
|
638
|
+
fs.mkdirSync(entry, { recursive: true });
|
|
639
|
+
fs.chmodSync(entry, 0o000);
|
|
640
|
+
try {
|
|
641
|
+
throwsNaming(
|
|
642
|
+
"a mode-000 DIRECTORY record is non-regular by classification — its open EACCES is never reached",
|
|
643
|
+
() => readMetaIdentityByGardenId(GID_A, specialStore),
|
|
644
|
+
"not a regular file",
|
|
645
|
+
);
|
|
646
|
+
} finally {
|
|
647
|
+
fs.chmodSync(entry, 0o700);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
} finally {
|
|
651
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// --- #52: both production readers converge on the one fd reader --------------
|
|
656
|
+
// The runtime cells above can only prove the seam they are pointed at. This is the
|
|
657
|
+
// fence that keeps a third `readFileSync(<path>)` from growing back at either boundary —
|
|
658
|
+
// the same structural discipline `readActiveStoreEntries` holds for the entry kind, and
|
|
659
|
+
// the one `pi_settings_io` learned when a second settings writer was missed.
|
|
660
|
+
{
|
|
661
|
+
const src = fs.readFileSync(new URL("../pi-extensions/lib/meta-session.ts", import.meta.url), "utf8");
|
|
662
|
+
const bodyOf = (decl: string): string => {
|
|
663
|
+
const start = src.indexOf(decl);
|
|
664
|
+
assert.ok(start >= 0, `source fence: ${decl} not found`);
|
|
665
|
+
const next = src.indexOf("\nexport ", start + decl.length);
|
|
666
|
+
return src.slice(start, next < 0 ? undefined : next);
|
|
667
|
+
};
|
|
668
|
+
for (const decl of ["export function makeStoreRecordReader", "export function readMetaIdentityByGardenId"]) {
|
|
669
|
+
const body = bodyOf(decl);
|
|
670
|
+
ok(
|
|
671
|
+
`source fence: ${decl.replace("export function ", "")} reads through readStoreRecordFile`,
|
|
672
|
+
/readStoreRecordFile\(/.test(body),
|
|
673
|
+
);
|
|
674
|
+
ok(
|
|
675
|
+
`source fence: ${decl.replace("export function ", "")} keeps no path-based readFileSync`,
|
|
676
|
+
!/fs\.readFileSync\(/.test(body),
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const reader = bodyOf("export function readStoreRecordFile");
|
|
680
|
+
ok("source fence: the fd reader opens with O_NOFOLLOW (a swapped symlink fails the open)", /O_NOFOLLOW/.test(reader));
|
|
681
|
+
ok("source fence: …and with O_NONBLOCK, so a fifo cannot block the open", /O_NONBLOCK/.test(reader));
|
|
682
|
+
ok("source fence: …decides the kind on the fd via fstat, not on the name", /fstatSync\(fd\)/.test(reader));
|
|
683
|
+
ok("source fence: …and closes the description in a finally", /finally \{\n\t\tfs\.closeSync\(fd\);/.test(reader));
|
|
684
|
+
}
|
|
685
|
+
|
|
186
686
|
// --- rule 1: a `.meta.json` that is not a regular file ------------------------
|
|
187
687
|
// Pure first (the injected view the certification actually reasons over)…
|
|
188
688
|
{
|
|
@@ -374,7 +874,7 @@ withStore(
|
|
|
374
874
|
() => readMetaIdentityByGardenId(GID_A, dir),
|
|
375
875
|
FRESH_CUT_COMMAND,
|
|
376
876
|
);
|
|
377
|
-
const listed = listAllMetaIdentities(
|
|
877
|
+
const listed = listAllMetaIdentities(readActiveStoreEntries(dir), makeStoreRecordReader(dir));
|
|
378
878
|
ok(
|
|
379
879
|
"the listing's drift diagnostic names the verb too (a facts surface still says what to do)",
|
|
380
880
|
listed.errors.length === 1 && (listed.errors[0]?.message ?? "").includes(FRESH_CUT_COMMAND),
|