@junghanacs/entwurf 0.24.0 → 0.25.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 (75) hide show
  1. package/CHANGELOG.md +564 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/DELIVERY.md +24 -1
  4. package/README.md +2 -2
  5. package/VERIFY.md +20 -2
  6. package/demo/README.md +5 -2
  7. package/docs/acp-backend-rail.md +49 -2
  8. package/docs/setup-clean-host.md +4 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +5 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +13 -3
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +11 -0
  18. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  19. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  20. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  21. package/mcp/entwurf-bridge/src/index.ts +5 -4
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +27 -154
  24. package/pi-extensions/lib/compaction-send-guard.ts +5 -3
  25. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  26. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  27. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  30. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  32. package/pi-extensions/lib/herdr-fresh-call.ts +13 -3
  33. package/pi-extensions/lib/herdr-placement.ts +11 -0
  34. package/protocol.js +0 -8
  35. package/run.sh +202 -33
  36. package/scripts/check-entwurf-v2-surface.ts +289 -10
  37. package/scripts/check-gate-qualification.ts +2354 -11
  38. package/scripts/check-herdr-fresh-call.ts +10 -2
  39. package/scripts/check-herdr-placement.ts +51 -0
  40. package/scripts/check-herdr-plugin-build.ts +11 -4
  41. package/scripts/check-herdr-plugin.ts +745 -38
  42. package/scripts/check-mux-launcher-fence.ts +37 -0
  43. package/scripts/check-pi-launch.ts +7 -4
  44. package/scripts/check-release-gate-outcomes.ts +165 -0
  45. package/scripts/doctor-pi-provider.ts +44 -4
  46. package/scripts/herdr-rails.mjs +75 -0
  47. package/scripts/lib/qualification-acceptance.ts +146 -0
  48. package/scripts/lib/qualification-artifact.ts +359 -0
  49. package/scripts/lib/qualification-collection.ts +384 -0
  50. package/scripts/lib/qualification-gh.ts +231 -0
  51. package/scripts/lib/qualification-ledger.ts +669 -0
  52. package/scripts/lib/qualification-oracle-core.ts +279 -0
  53. package/scripts/lib/qualification-receipt.ts +561 -0
  54. package/scripts/lib/qualification-release.ts +271 -0
  55. package/scripts/meta-bridge-state.py +18 -1
  56. package/scripts/mutants/bridge-command-boot.json +38 -0
  57. package/scripts/mutants/control-send-receipt.json +235 -0
  58. package/scripts/mutants/herdr-fresh-call.json +4 -4
  59. package/scripts/mutants/herdr-plugin-build.json +7 -11
  60. package/scripts/mutants/herdr-plugin.json +177 -9
  61. package/scripts/mutants/meta-retire.json +22 -0
  62. package/scripts/mutants/pack-install.json +4 -4
  63. package/scripts/mutants/pi-floor.json +17 -0
  64. package/scripts/mutants/qualification-ledger.json +489 -0
  65. package/scripts/mutants/release-gate.json +48 -0
  66. package/scripts/mutants/retired-sent-surface.json +32 -0
  67. package/scripts/mutants/v2-surface.json +7 -7
  68. package/scripts/qualification-oracle.ts +124 -0
  69. package/scripts/smoke-codex-fresh-live.ts +94 -13
  70. package/scripts/smoke-herdr-fresh-call-live.ts +15 -8
  71. package/scripts/smoke-herdr-raw-install-live.sh +66 -4
  72. package/scripts/smoke-meta-install-state.sh +107 -1
  73. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  74. package/scripts/smoke-pi-provider-state.sh +106 -0
  75. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -0,0 +1,359 @@
1
+ /**
2
+ * qualification-artifact — admit ONE CI collection artifact without the network
3
+ * (#124 stage 3a). The caller injects the artifact metadata GitHub returned and the raw
4
+ * zip bytes it downloaded; this module never fetches, never extracts to disk, and never
5
+ * uses `gh run download` output (an extracted tree carries no artifact digest).
6
+ *
7
+ * selectArtifact exactly one artifact named for (runId, runAttempt), else RED.
8
+ * admitArtifact metadata (name, run, head SHA, not expired) + digest = sha256 of
9
+ * the zip bytes + zip containment + collection completeness + the
10
+ * collection's own run/attempt/head + core self-consistency.
11
+ * readZipEntries a minimal central-directory reader over Node's built-in zlib:
12
+ * stored or deflated entries only, no zip64, no encryption; every
13
+ * entry name must be a declared collection basename; symlinks,
14
+ * directories, duplicates and oversize are refused BY NAME.
15
+ * checkCoreConsistency each receipt's core.sha256 is recomputed from its own
16
+ * {files, node, nodeModules}; all receipts and the collection agree;
17
+ * optionally core.files must equal an independent CORE_FILES
18
+ * recomputation. node/node_modules stay a CI WITNESS — nothing here
19
+ * hashes the operator host's node_modules as if it were the runner's.
20
+ *
21
+ * Every error is a named string; an empty list is the only admission. None of this is a
22
+ * verdict — composeRelease decides that, and only over admitted collections.
23
+ */
24
+
25
+ import { createHash } from "node:crypto";
26
+ import * as zlib from "node:zlib";
27
+ import { type Collection, checkCollectionData, mapReader } from "./qualification-collection.ts";
28
+ import { canonicalJson, coreSha256 } from "./qualification-receipt.ts";
29
+
30
+ export const ENTRY_NAME = /^(collection\.json|g[0-9]{3}\.json)$/;
31
+ export const MAX_ENTRY_BYTES = 8 * 1024 * 1024;
32
+ export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
33
+ /** Whole-archive cap, checked before a single byte is parsed or inflated. */
34
+ export const MAX_ZIP_BYTES = 80 * 1024 * 1024;
35
+
36
+ /** The fields of a GitHub `actions/runs/{id}/artifacts` item this module reads. */
37
+ export interface ApiArtifact {
38
+ id: number;
39
+ name: string;
40
+ digest: string | null;
41
+ expired: boolean;
42
+ workflow_run: { id: number; head_sha: string } | null;
43
+ }
44
+
45
+ export function artifactName(runId: string, runAttempt: string): string {
46
+ return `qualification-collection-${runId}-${runAttempt}`;
47
+ }
48
+
49
+ export function selectArtifact(
50
+ list: ApiArtifact[],
51
+ runId: string,
52
+ runAttempt: string,
53
+ ): { ok: true; artifact: ApiArtifact } | { ok: false; errors: string[] } {
54
+ const name = artifactName(runId, runAttempt);
55
+ const hits = list.filter((a) => a.name === name);
56
+ if (hits.length !== 1)
57
+ return { ok: false, errors: [`artifact-select: ${hits.length} artifacts named ${name}, need exactly 1`] };
58
+ return { ok: true, artifact: hits[0] };
59
+ }
60
+
61
+ // ── zip ─────────────────────────────────────────────────────────────────────
62
+
63
+ const EOCD = 0x06054b50;
64
+ const CEN = 0x02014b50;
65
+ const LOC = 0x04034b50;
66
+
67
+ const DESC = 0x08074b50;
68
+ const ZIP64_LOCATOR = 0x07064b50;
69
+
70
+ interface CentralEntry {
71
+ name: string;
72
+ madeBy: number;
73
+ flags: number;
74
+ method: number;
75
+ crc: number;
76
+ csize: number;
77
+ usize: number;
78
+ extAttr: number;
79
+ locOff: number;
80
+ }
81
+
82
+ /**
83
+ * Read every entry of a zip held in memory, refusing anything a collection may not
84
+ * contain. Structure first, content second — nothing is inflated until the whole archive
85
+ * is proven to be exactly its listed entries:
86
+ * 1. the archive is at most MAX_ZIP_BYTES (checked before any parsing);
87
+ * 2. the EOCD record ends the archive exactly (its comment length included), is single
88
+ * disk, and is not zip64;
89
+ * 3. the central directory ends at the EOCD and is consumed EXACTLY by its entry count
90
+ * (a lowered count cannot hide a listed entry);
91
+ * 4. the local records tile [0, cdOff) with no gap and no overlap (an unlisted local
92
+ * entry or smuggled bytes cannot hide either). A data descriptor (flag bit 3) is
93
+ * accepted in its two standard forms only and must agree with the central record;
94
+ * any other layout is refused by name rather than loosened to pass.
95
+ */
96
+ export function readZipEntries(zip: Buffer): { files: Record<string, Buffer>; errors: string[] } {
97
+ const files: Record<string, Buffer> = {};
98
+ const fail = (e: string) => ({ files, errors: [e] });
99
+ if (zip.length > MAX_ZIP_BYTES) return fail(`zip-oversize: archive (${zip.length} bytes)`);
100
+ if (zip.length < 22) return fail("zip-format: too short for an end-of-central-directory record");
101
+
102
+ let eocd = -1;
103
+ let sawSignature = false;
104
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i--) {
105
+ if (zip.readUInt32LE(i) !== EOCD) continue;
106
+ sawSignature = true;
107
+ if (i + 22 + zip.readUInt16LE(i + 20) === zip.length) {
108
+ eocd = i;
109
+ break;
110
+ }
111
+ }
112
+ if (eocd < 0) {
113
+ return fail(
114
+ sawSignature
115
+ ? "zip-format: the end-of-central-directory record does not end the archive (trailing bytes or comment length)"
116
+ : "zip-format: no end-of-central-directory record",
117
+ );
118
+ }
119
+ const diskNo = zip.readUInt16LE(eocd + 4);
120
+ const cdDisk = zip.readUInt16LE(eocd + 6);
121
+ const onDisk = zip.readUInt16LE(eocd + 8);
122
+ const count = zip.readUInt16LE(eocd + 10);
123
+ const cdSize = zip.readUInt32LE(eocd + 12);
124
+ const cdOff = zip.readUInt32LE(eocd + 16);
125
+ if (count === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff)
126
+ return fail("zip-format: zip64 is not accepted");
127
+ if (eocd >= 20 && zip.readUInt32LE(eocd - 20) === ZIP64_LOCATOR) return fail("zip-format: zip64 is not accepted");
128
+ if (diskNo !== 0 || cdDisk !== 0 || onDisk !== count) return fail("zip-format: multi-disk archives are not accepted");
129
+ if (cdOff + cdSize !== eocd)
130
+ return fail("zip-format: the central directory does not end at the end-of-central-directory record");
131
+
132
+ const entries: CentralEntry[] = [];
133
+ let p = cdOff;
134
+ for (let n = 0; n < count; n++) {
135
+ if (p + 46 > eocd || zip.readUInt32LE(p) !== CEN)
136
+ return fail(`zip-format: central directory entry ${n} is malformed`);
137
+ const nameLen = zip.readUInt16LE(p + 28);
138
+ const extraLen = zip.readUInt16LE(p + 30);
139
+ const commentLen = zip.readUInt16LE(p + 32);
140
+ if (p + 46 + nameLen + extraLen + commentLen > eocd)
141
+ return fail(`zip-format: central directory entry ${n} overruns`);
142
+ entries.push({
143
+ name: zip.subarray(p + 46, p + 46 + nameLen).toString("utf8"),
144
+ madeBy: zip.readUInt16LE(p + 4) >> 8,
145
+ flags: zip.readUInt16LE(p + 8),
146
+ method: zip.readUInt16LE(p + 10),
147
+ crc: zip.readUInt32LE(p + 16),
148
+ csize: zip.readUInt32LE(p + 20),
149
+ usize: zip.readUInt32LE(p + 24),
150
+ extAttr: zip.readUInt32LE(p + 38),
151
+ locOff: zip.readUInt32LE(p + 42),
152
+ });
153
+ p += 46 + nameLen + extraLen + commentLen;
154
+ }
155
+ if (p !== cdOff + cdSize) {
156
+ return fail(`zip-format: the central directory has ${cdOff + cdSize - p} bytes not covered by its entry count`);
157
+ }
158
+
159
+ // Local records must tile [0, cdOff) exactly.
160
+ const spans: { start: number; end: number; dataStart: number; e: CentralEntry }[] = [];
161
+ for (const e of entries) {
162
+ const label = JSON.stringify(e.name);
163
+ if (e.locOff + 30 > cdOff || zip.readUInt32LE(e.locOff) !== LOC)
164
+ return fail(`zip-format: ${label} local header missing`);
165
+ const lNameLen = zip.readUInt16LE(e.locOff + 26);
166
+ const lExtraLen = zip.readUInt16LE(e.locOff + 28);
167
+ if (zip.subarray(e.locOff + 30, e.locOff + 30 + lNameLen).toString("utf8") !== e.name) {
168
+ return fail(`zip-format: ${label} local name differs from the central directory`);
169
+ }
170
+ const dataStart = e.locOff + 30 + lNameLen + lExtraLen;
171
+ let end = dataStart + e.csize;
172
+ if (e.flags & 8) {
173
+ const signed = end + 16 <= cdOff && zip.readUInt32LE(end) === DESC;
174
+ const d = signed ? end + 4 : end;
175
+ if (d + 12 > cdOff) return fail(`zip-format: ${label} data descriptor out of bounds`);
176
+ if (zip.readUInt32LE(d) !== e.crc || zip.readUInt32LE(d + 4) !== e.csize || zip.readUInt32LE(d + 8) !== e.usize) {
177
+ return fail(`zip-format: ${label} data descriptor disagrees with the central directory`);
178
+ }
179
+ end = d + 12;
180
+ }
181
+ if (end > cdOff) return fail(`zip-format: ${label} data out of bounds`);
182
+ spans.push({ start: e.locOff, end, dataStart, e });
183
+ }
184
+ spans.sort((x, y) => x.start - y.start);
185
+ let cursor = 0;
186
+ for (const s of spans) {
187
+ if (s.start !== cursor) return fail("zip-format: unlisted bytes between local entries (a gap or an overlap)");
188
+ cursor = s.end;
189
+ }
190
+ if (cursor !== cdOff) return fail("zip-format: unlisted bytes before the central directory");
191
+
192
+ // Content, entry by entry.
193
+ const errors: string[] = [];
194
+ let total = 0;
195
+ for (const { e, dataStart } of spans) {
196
+ const label = JSON.stringify(e.name);
197
+ const unixMode = e.madeBy === 3 ? e.extAttr >>> 16 : 0;
198
+ if ((unixMode & 0o170000) === 0o120000) {
199
+ errors.push(`zip-symlink: ${label}`);
200
+ continue;
201
+ }
202
+ if (e.name.endsWith("/") || (unixMode & 0o170000) === 0o040000) {
203
+ errors.push(`zip-directory: ${label}`);
204
+ continue;
205
+ }
206
+ if (!ENTRY_NAME.test(e.name)) {
207
+ errors.push(`zip-name: ${label} is not collection.json or gNNN.json`);
208
+ continue;
209
+ }
210
+ if (Object.hasOwn(files, e.name)) {
211
+ errors.push(`zip-duplicate: ${label}`);
212
+ continue;
213
+ }
214
+ if (e.flags & 1) {
215
+ errors.push(`zip-encrypted: ${label}`);
216
+ continue;
217
+ }
218
+ total += e.usize;
219
+ if (e.usize > MAX_ENTRY_BYTES || e.csize > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES) {
220
+ errors.push(`zip-oversize: ${label} (${e.usize} bytes)`);
221
+ continue;
222
+ }
223
+ const raw = zip.subarray(dataStart, dataStart + e.csize);
224
+ let data: Buffer;
225
+ try {
226
+ if (e.method === 0) data = Buffer.from(raw);
227
+ else if (e.method === 8) data = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, e.usize) });
228
+ else {
229
+ errors.push(`zip-method: ${label} uses method ${e.method}`);
230
+ continue;
231
+ }
232
+ } catch (err) {
233
+ errors.push(`zip-inflate: ${label} ${err instanceof Error ? err.message : String(err)}`);
234
+ continue;
235
+ }
236
+ if (data.length !== e.usize || zlib.crc32(data) !== e.crc) {
237
+ errors.push(`zip-crc: ${label} size or CRC does not match its header`);
238
+ continue;
239
+ }
240
+ files[e.name] = data;
241
+ }
242
+ return { files, errors };
243
+ }
244
+
245
+ // ── core self-consistency ───────────────────────────────────────────────────
246
+
247
+ const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
248
+
249
+ /** The C0 fields a receipt must carry before its core can be recomputed or witnessed. */
250
+ export function coreShapeError(core: unknown): string | null {
251
+ if (!isObj(core)) return "core is not an object";
252
+ if (!isObj(core.files) || !Object.values(core.files).every((v) => typeof v === "string")) return "core.files";
253
+ const node = core.node;
254
+ if (
255
+ !isObj(node) ||
256
+ typeof node.version !== "string" ||
257
+ typeof node.execPath !== "string" ||
258
+ typeof node.execSha256 !== "string"
259
+ ) {
260
+ return "core.node";
261
+ }
262
+ const nm = core.nodeModules;
263
+ if (
264
+ !isObj(nm) ||
265
+ typeof nm.present !== "boolean" ||
266
+ !(nm.sha256 === null || typeof nm.sha256 === "string") ||
267
+ !Array.isArray(nm.excluded) ||
268
+ !nm.excluded.every((x) => typeof x === "string") ||
269
+ typeof nm.files !== "number" ||
270
+ typeof nm.links !== "number"
271
+ ) {
272
+ return "core.nodeModules";
273
+ }
274
+ if (typeof core.sha256 !== "string") return "core.sha256";
275
+ return null;
276
+ }
277
+
278
+ export function checkCoreConsistency(
279
+ files: Record<string, Buffer>,
280
+ collection: Collection,
281
+ independentCoreFiles?: Record<string, string>,
282
+ ): string[] {
283
+ const errors: string[] = [];
284
+ const shas = new Set<string>();
285
+ for (const a of collection.attempted) {
286
+ if (a.outcome !== "receipt-written" || !a.file || !files[a.file]) continue;
287
+ let core: { files: Record<string, string>; node: unknown; nodeModules: unknown; sha256: string };
288
+ try {
289
+ core = JSON.parse(files[a.file].toString("utf8")).core;
290
+ } catch {
291
+ errors.push(`core-unreadable: ${a.file}`);
292
+ continue;
293
+ }
294
+ const shapeErr = coreShapeError(core);
295
+ if (shapeErr) {
296
+ errors.push(`core-shape: ${a.file} ${shapeErr}`);
297
+ continue;
298
+ }
299
+ const recomputed = coreSha256({ files: core.files, node: core.node, nodeModules: core.nodeModules });
300
+ if (recomputed !== core.sha256)
301
+ errors.push(`core-inconsistent: ${a.file} core.sha256 is not the hash of its own parts`);
302
+ shas.add(recomputed);
303
+ if (independentCoreFiles && canonicalJson(core.files) !== canonicalJson(independentCoreFiles)) {
304
+ errors.push(`core-files-mismatch: ${a.file} CORE_FILES differ from the independent recomputation`);
305
+ }
306
+ }
307
+ if (shas.size > 1) errors.push("core-split: receipts of one collection disagree on C0");
308
+ const [only] = [...shas];
309
+ if (only && only !== collection.candidate.coreSha256) {
310
+ errors.push("core-collection-mismatch: the collection's candidate C0 is not its receipts' C0");
311
+ }
312
+ return errors;
313
+ }
314
+
315
+ // ── admission ───────────────────────────────────────────────────────────────
316
+
317
+ export interface ArtifactInput {
318
+ runId: string;
319
+ runAttempt: string;
320
+ headSha: string;
321
+ artifact: ApiArtifact;
322
+ zip: Buffer;
323
+ /** Present for the FINAL SHA only: CORE_FILES recomputed from the final tree. */
324
+ independentCoreFiles?: Record<string, string>;
325
+ }
326
+
327
+ export function admitArtifact(input: ArtifactInput): {
328
+ errors: string[];
329
+ files: Record<string, Buffer> | null;
330
+ collection: Collection | null;
331
+ /** How node/node_modules are known: only as the run's own witness. */
332
+ witness: string;
333
+ } {
334
+ const errors: string[] = [];
335
+ const a = input.artifact;
336
+ const tag = `run ${input.runId}#${input.runAttempt}`;
337
+ const witness = `node/node_modules: CI-witnessed by ${tag}, not recomputed here`;
338
+ if (a.name !== artifactName(input.runId, input.runAttempt)) errors.push(`artifact-name: ${a.name}`);
339
+ if (!a.workflow_run || String(a.workflow_run.id) !== input.runId) errors.push(`artifact-run: not produced by ${tag}`);
340
+ if (!a.workflow_run || a.workflow_run.head_sha !== input.headSha)
341
+ errors.push("artifact-head: not produced at this SHA");
342
+ if (a.expired) errors.push("artifact-expired: the evidence is gone");
343
+ const zipSha = createHash("sha256").update(input.zip).digest("hex");
344
+ if (a.digest !== `sha256:${zipSha}`)
345
+ errors.push(`artifact-digest: zip sha256 ${zipSha} vs recorded ${String(a.digest)}`);
346
+ if (errors.length > 0) return { errors, files: null, collection: null, witness };
347
+
348
+ const z = readZipEntries(input.zip);
349
+ if (z.errors.length > 0) return { errors: z.errors, files: null, collection: null, witness };
350
+ const { errors: cErrors, collection } = checkCollectionData(mapReader(z.files), Object.keys(z.files).sort());
351
+ errors.push(...cErrors);
352
+ if (!collection) return { errors, files: null, collection: null, witness };
353
+ if (collection.runId !== input.runId || collection.runAttempt !== input.runAttempt) {
354
+ errors.push(`collection-run: records ${collection.runId}#${collection.runAttempt}, artifact is ${tag}`);
355
+ }
356
+ if (collection.candidate.head !== input.headSha) errors.push("collection-head: measured another SHA");
357
+ errors.push(...checkCoreConsistency(z.files, collection, input.independentCoreFiles));
358
+ return { errors, files: errors.length === 0 ? z.files : null, collection, witness };
359
+ }