bareloop 0.20.0 → 0.22.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.
package/src/bundle.js ADDED
@@ -0,0 +1,713 @@
1
+ // Export M1 — `docs/product/EXPORT-BUILD.md` (frozen 2026-09-05). A BUNDLE is a
2
+ // directory an importer can `npm install` and run without this repo: the signed
3
+ // job spec (close paths rewritten to `$BARELOOP_BUNDLE/close/<script>`), the
4
+ // close scripts it runs, and the job's whole registry history (bridges). This
5
+ // module is the pure, testable half — it writes/reads the directory and its
6
+ // signature; nothing here talks to a provider or runs a close.
7
+ //
8
+ // N4 (the spec's negative POC, load-bearing): the manifest's `bundleHash`
9
+ // verify is not a courtesy. `jobSpecHash` covers `close[].cmd` as a PATH, not
10
+ // the script's bytes, so a swapped close script leaves the spec's own
11
+ // signature intact and the close-first precheck would read the fake result as
12
+ // an early green. `readBundle`'s `bundle-tampered` red is therefore the ONLY
13
+ // thing standing between a swapped script and a fake green, and every caller
14
+ // (M2's runner) must run it before anything else touches the bundle.
15
+ //
16
+ // Bridges and history are OUTSIDE the bundle hash on purpose (they are
17
+ // records, not the arbiter) — only `spec.json` and `close/*` are signed.
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { createRequire } from 'node:module';
21
+ import {
22
+ existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, appendFileSync,
23
+ } from 'node:fs';
24
+ import { basename, isAbsolute, join, resolve } from 'node:path';
25
+ import { isObj, isNonEmptyString } from './validate.js';
26
+ import { jobSpecHash } from './job.js';
27
+ import { loadRegistry } from './bridges.js';
28
+ import { closeStagesOf } from './plan.js';
29
+ import { shapeForkName } from './reuse.js';
30
+ import { absolutePathLiteralsOf, hashCloseScriptBytes } from './close-integrity.js';
31
+ // The live export list IS `src/index.js`'s own `Object.keys` — never a second,
32
+ // hand-kept copy that can drift from the real surface a close script can
33
+ // legally import. This module is itself exported from `index.js` (a cycle by
34
+ // design): the binding is only READ inside function bodies, at call time,
35
+ // long after both modules have finished evaluating, so the cycle is safe.
36
+ import * as bareloopExports from './index.js';
37
+
38
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
39
+
40
+ const BUNDLE_SCHEMA = 'bundle-v1';
41
+ const BUNDLE_TOKEN = '$BARELOOP_BUNDLE';
42
+
43
+ /** @param {string} p @returns {boolean} */
44
+ function isDir(p) {
45
+ try { return statSync(p).isDirectory(); } catch { return false; }
46
+ }
47
+
48
+ /** @param {Buffer|string} content @returns {string} sha256 hex */
49
+ function sha256(content) {
50
+ return createHash('sha256').update(content).digest('hex');
51
+ }
52
+
53
+ /**
54
+ * The ONE bundle-hash formula: sha256 over the sorted `path:contentSha256`
55
+ * lines of a files map. `bundleHash(dir)` and `exportBundle` both go through
56
+ * this so minting and re-verifying can never drift into two spellings.
57
+ * @param {Record<string, string>} files path -> sha256 hex
58
+ * @returns {string} sha256 hex
59
+ */
60
+ function hashFilesMap(files) {
61
+ const lines = Object.keys(files).sort().map((p) => `${p}:${files[p]}`);
62
+ return sha256(lines.join('\n'));
63
+ }
64
+
65
+ /**
66
+ * Read `spec.json` and every `close/*` file off disk and hash them the same
67
+ * way `exportBundle` did at mint time. Never throws: a missing file (a
68
+ * deleted close script, a missing spec.json) is reported as absent so a
69
+ * caller comparing against a stored hash reads it as tampered rather than
70
+ * crashing.
71
+ * @param {string} dir bundle directory
72
+ * @returns {{ files: Record<string, string>, missing: string[] }}
73
+ */
74
+ function collectHashedFiles(dir) {
75
+ /** @type {Record<string, string>} */
76
+ const files = {};
77
+ /** @type {string[]} */
78
+ const missing = [];
79
+ const specPath = join(dir, 'spec.json');
80
+ if (existsSync(specPath)) files['spec.json'] = sha256(readFileSync(specPath));
81
+ else missing.push('spec.json');
82
+ const closeDir = join(dir, 'close');
83
+ if (isDir(closeDir)) {
84
+ for (const f of readdirSync(closeDir).sort()) {
85
+ files[`close/${f}`] = sha256(readFileSync(join(closeDir, f)));
86
+ }
87
+ } else missing.push('close/');
88
+ return { files, missing };
89
+ }
90
+
91
+ /**
92
+ * Recompute the bundle hash from what is ACTUALLY on disk right now (never
93
+ * trusts the stored manifest). Throws only if `dir` itself does not exist —
94
+ * every other absence (a missing spec.json, an empty close/) folds into the
95
+ * hash as an omitted file, which is exactly what makes a swapped/deleted
96
+ * script change the hash.
97
+ * @param {string} dir bundle directory
98
+ * @returns {string} sha256 hex
99
+ */
100
+ export function bundleHash(dir) {
101
+ if (!isDir(dir)) throw new Error(`bundleHash: not a directory: ${dir}`);
102
+ const { files } = collectHashedFiles(dir);
103
+ return hashFilesMap(files);
104
+ }
105
+
106
+ /**
107
+ * The pattern a close script's import line must match to be rewritten/checked:
108
+ * a relative specifier that reaches into this repo's `src/` (`../src/x.js`,
109
+ * `./src/x.js`, `../../src/x.js`, …). A script importing `bareloop` itself
110
+ * (the installed package, post-rewrite) matches on the literal name instead.
111
+ * @param {string} spec the quoted module specifier
112
+ * @returns {boolean}
113
+ */
114
+ function isBareloopSrcImport(spec) {
115
+ return spec === 'bareloop' || /^(\.\.?\/)+src\//.test(spec);
116
+ }
117
+
118
+ // Every STATIC import statement, clause captured whole (undefined when there
119
+ // is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
120
+ // the shape check below is what decides legality, not this regex.
121
+ const STATIC_IMPORT_RE = /import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g;
122
+ // Dynamic `import('x')` (and `await import('x')`, `import ('x')`, …) — no
123
+ // clause exists for this form at all, so it is ALWAYS unparsed when it
124
+ // targets something this bundle would otherwise have to check.
125
+ const DYNAMIC_IMPORT_RE = /import\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
126
+
127
+ /** @param {string} spec @returns {boolean} */
128
+ function isRelative(spec) {
129
+ return spec.startsWith('./') || spec.startsWith('../');
130
+ }
131
+
132
+ // F8/F129 — `absolutePathLiteralsOf` (a close judges the cwd the runner
133
+ // gives it, never a path baked into the script itself) now lives in
134
+ // `src/close-integrity.js`, shared with the run-start precheck that applies
135
+ // the SAME rule to every job, not only exports. Never a second spelling.
136
+
137
+ /**
138
+ * Every import in a close script this bundle has an opinion about: a
139
+ * relative import (src-bound or not — a sibling file is exactly the case
140
+ * that must be caught) and the bare `'bareloop'` package specifier. A
141
+ * node:/third-party specifier is filtered out here and never reaches the
142
+ * legality check — this module has nothing to say about those.
143
+ * @param {string} source close script text
144
+ * @returns {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]}
145
+ */
146
+ function relevantImportsOf(source) {
147
+ /** @type {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]} */
148
+ const out = [];
149
+ for (const m of source.matchAll(STATIC_IMPORT_RE)) {
150
+ const [raw, clause, , spec] = m;
151
+ if (!isRelative(spec) && spec !== 'bareloop') continue;
152
+ out.push({ raw: raw.trim(), spec, clause: clause === undefined ? null : clause.trim(), dynamic: false });
153
+ }
154
+ for (const m of source.matchAll(DYNAMIC_IMPORT_RE)) {
155
+ const [raw, , spec] = m;
156
+ if (!isRelative(spec) && spec !== 'bareloop') continue;
157
+ out.push({ raw: raw.trim(), spec, clause: null, dynamic: true });
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * The ONLY import shape this module can verify: a plain `{ a, b as c }`
164
+ * named clause. Anything else — a default import, `* as ns`, a mix of
165
+ * default+named/namespace, a bare side-effect import, or (by construction,
166
+ * since it carries no clause at all) a dynamic `import()` — is UNPARSED, not
167
+ * silently accepted: a legality check that skips what it cannot read is a
168
+ * blind instrument, and blind is not the same as clean.
169
+ * @param {string|null} clause @param {boolean} dynamic
170
+ * @returns {boolean}
171
+ */
172
+ function isPlainNamedClause(clause, dynamic) {
173
+ return !dynamic && clause !== null && /^\{[^{}]*\}$/.test(clause);
174
+ }
175
+
176
+ /**
177
+ * Rewrite every `from '<relative-into-src>'` specifier in a close script's
178
+ * source to `from 'bareloop'` — the one substitution the spec names (N-facts,
179
+ * "close scripts copied verbatim except …"). Only the specifier moves; the
180
+ * imported names are untouched, so a script importing `JUDGED_MARKER` from
181
+ * `../src/kinds.js` imports it from `bareloop` instead, byte-identical
182
+ * otherwise.
183
+ * @param {string} source
184
+ * @returns {string}
185
+ */
186
+ function rewriteSrcImports(source) {
187
+ return source.replace(/from\s+(['"])(\.\.?\/)+src\/[^'"]*\1/g, "from 'bareloop'");
188
+ }
189
+
190
+ /**
191
+ * Parse one `close[].cmd` into its no-shell argv (the runner spawns it
192
+ * without a shell — `src/ralph.js`, whitespace split). Refuses anything that
193
+ * is not exactly `node <absolute path ending .mjs> <args…>` — the one shape
194
+ * this bundle can relocate, because it is the one shape whose first argv
195
+ * token IS a path this module controls.
196
+ * @param {string} cmd
197
+ * @returns {{ ok: true, scriptPath: string, rest: string[] } | { ok: false }}
198
+ */
199
+ function parseNodeCmd(cmd) {
200
+ if (!isNonEmptyString(cmd)) return { ok: false };
201
+ const parts = cmd.trim().split(/\s+/);
202
+ if (parts.length < 2 || parts[0] !== 'node') return { ok: false };
203
+ const scriptPath = parts[1];
204
+ if (!isAbsolute(scriptPath) || !scriptPath.endsWith('.mjs')) return { ok: false };
205
+ return { ok: true, scriptPath, rest: parts.slice(2) };
206
+ }
207
+
208
+ /**
209
+ * EXPORT — mints a bundle directory from a signed job spec (`docs/product/
210
+ * EXPORT-BUILD.md`, "The bundle"). Every check below runs BEFORE anything
211
+ * touches disk (typed reds, nothing written): a partially-written bundle
212
+ * would be worse than no bundle, since `readBundle`'s tamper check has
213
+ * nothing trustworthy to compare against yet.
214
+ *
215
+ * Refusals, each a distinct red code:
216
+ * - `no-bridge-at-hash` — neither of the job's two possible bridge names —
217
+ * `spec.job` itself, or its SHAPE FORK `shapeForkName(spec.job,
218
+ * closeStagesOf(spec))` (the name `writeGreenRow`/`writeRunGreenRow` in
219
+ * `src/reuse.js` mint whenever a run's close-stage set differs from the
220
+ * base bridge's `closeStageNames` — same derivation, imported rather than
221
+ * re-spelled, so the two names cannot drift) — carries a recorded specHash
222
+ * (bridge-level or on some version) equal to `jobSpecHash(spec)`: nothing
223
+ * has ever greened THIS exact spec, so there is nothing proven to ship.
224
+ * Every candidate bridge that DOES exist (base and/or fork) still ships
225
+ * into `bridges/` — the spec says "the job's WHOLE registry history
226
+ * (greens and reds)", not just the one that matched.
227
+ * - `close-cmd-unrelocatable` — a `close[].cmd` is not `node <abs .mjs> …`.
228
+ * - `close-script-missing` — a cmd names a script `closeScripts` does not
229
+ * carry the source for.
230
+ * - `close-script-collision` — two different script paths share a basename;
231
+ * copying both into one flat `close/` directory would silently clobber one.
232
+ * - `close-import-unexported` — a close script imports a name from
233
+ * bareloop's own src that `src/index.js` does not export.
234
+ * - `close-import-unparsed` — a close script's import is a shape this
235
+ * module cannot verify at all (default import, `* as ns`, mixed
236
+ * default+named/namespace, a bare side-effect import, or a dynamic
237
+ * `import()`), or is a relative import that does not point into
238
+ * `src/` (a sibling file `close/` never ships, so it would be missing
239
+ * from the bundle). Fails safe rather than silently accepting what it
240
+ * cannot read.
241
+ * - `close-absolute-path` — a close script's source bakes in a string
242
+ * literal that is an absolute POSIX path which exists on the exporting
243
+ * machine and is outside the system-prefix allow-list (see
244
+ * `absolutePathLiteralsOf`): a close judges the cwd the runner gives it,
245
+ * never a path baked into the script — F8/F129, the live defect this
246
+ * guard would have refused at export time before any run.
247
+ * - `outdir-not-empty` — `outDir` exists and already holds files.
248
+ * - `registry-unreadable` — `registryDir` itself could not be read
249
+ * (bubbled from `loadRegistry`).
250
+ * @param {{ spec: any, closeScripts: Record<string, string>, registryDir: string, outDir: string, bareloopVersion: string }} o
251
+ * @returns {{ ok: boolean, reds: Red[], dir: string|null, bundleHash: string|null, manifest: any|null }}
252
+ */
253
+ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloopVersion }) {
254
+ /** @type {Red[]} */
255
+ const reds = [];
256
+ const red = (/** @type {string} */ code, /** @type {string} */ path, /** @type {string} */ detail) => reds.push({ code, path, detail });
257
+ const fail = () => ({ ok: false, reds, dir: null, bundleHash: null, manifest: null });
258
+
259
+ if (!isObj(spec) || !isNonEmptyString(spec.job)) {
260
+ red('invalid-value', 'spec.job', 'a job spec with a job slug is required');
261
+ return fail();
262
+ }
263
+ if (!Array.isArray(spec.close) || spec.close.length === 0) {
264
+ red('invalid-value', 'spec.close', 'a non-empty ordered close stage list is required to export');
265
+ return fail();
266
+ }
267
+ const scripts = isObj(closeScripts) ? closeScripts : {};
268
+
269
+ // outDir: refuse a non-empty destination before any other check runs, since
270
+ // this is the cheapest way to fail and the one most likely to be a mistake
271
+ // (re-exporting into a directory that already holds a prior bundle/blessing).
272
+ if (existsSync(outDir)) {
273
+ if (!isDir(outDir)) { red('invalid-value', 'outDir', 'exists and is not a directory'); return fail(); }
274
+ if (readdirSync(outDir).length > 0) { red('outdir-not-empty', outDir, 'exportBundle never merges into an existing directory'); return fail(); }
275
+ }
276
+
277
+ // registry: the job must have a bridge minted at THIS exact spec's hash.
278
+ const reg = loadRegistry(registryDir);
279
+ if (!reg.ok && reg.bridges.length === 0) {
280
+ red('registry-unreadable', registryDir, reg.reds.map((r) => `${r.code}:${r.path}`).join(', ') || 'the registry could not be read');
281
+ return fail();
282
+ }
283
+ const currentHash = jobSpecHash(spec);
284
+ // Two candidate bridge names for this job: its own name, and the SHAPE FORK
285
+ // `writeGreenRow` mints when a run's close-stage set differs from the base
286
+ // bridge's stored `closeStageNames` (src/reuse.js `shapeForkName`). The
287
+ // stage names are derived through the same `closeStagesOf` the writer uses,
288
+ // never re-spelled, so the fork name this reads can never drift from the
289
+ // fork name that gets minted.
290
+ const stageNames = (closeStagesOf(spec) ?? []).map((/** @type {any} */ s) => s.name);
291
+ const forkName = shapeForkName(spec.job, stageNames);
292
+ const candidateNames = new Set([spec.job, forkName]);
293
+ const candidateBridges = reg.bridges.filter((/** @type {any} */ b) => candidateNames.has(b.name));
294
+ const hashMatches = candidateBridges.some((/** @type {any} */ b) => (
295
+ b.specHash === currentHash
296
+ || (Array.isArray(b.versions) && b.versions.some((/** @type {any} */ v) => v.specHash === currentHash))
297
+ ));
298
+ if (!hashMatches) {
299
+ red('no-bridge-at-hash', 'spec', `neither "${spec.job}" nor its shape fork "${forkName}" in ${registryDir} carries a green at this spec's current hash (${currentHash.slice(0, 12)}…) — export ships a run that actually happened, never an unproven draft`);
300
+ }
301
+
302
+ // close[].cmd shape + import legality, over EVERY stage before writing any of them.
303
+ const exportNames = new Set(Object.keys(bareloopExports));
304
+ /** @type {Map<string, string>} scriptPath -> basename, dedup'd */
305
+ const byPath = new Map();
306
+ /** @type {Map<string, string>} basename -> scriptPath (collision detection) */
307
+ const byName = new Map();
308
+ spec.close.forEach((/** @type {any} */ stage, /** @type {number} */ i) => {
309
+ const at = `close.${i}.cmd`;
310
+ const parsed = parseNodeCmd(isObj(stage) ? stage.cmd : undefined);
311
+ if (!parsed.ok) { red('close-cmd-unrelocatable', at, `must be "node <absolute path>.mjs …" — got ${JSON.stringify(isObj(stage) ? stage.cmd : stage)}`); return; }
312
+ const { scriptPath } = parsed;
313
+ if (byPath.has(scriptPath)) return; // already checked this path from an earlier stage
314
+ const name = basename(scriptPath);
315
+ const collidesWith = byName.get(name);
316
+ if (collidesWith && collidesWith !== scriptPath) {
317
+ red('close-script-collision', at, `"${name}" is already claimed by ${collidesWith} — two different close scripts cannot share one basename in a flat close/ directory`);
318
+ return;
319
+ }
320
+ byName.set(name, scriptPath);
321
+ byPath.set(scriptPath, name);
322
+ const source = scripts[scriptPath];
323
+ if (!isNonEmptyString(source)) { red('close-script-missing', at, `closeScripts has no source for ${scriptPath}`); return; }
324
+ for (const imp of relevantImportsOf(source)) {
325
+ if (!isPlainNamedClause(imp.clause, imp.dynamic) || (isRelative(imp.spec) && !isBareloopSrcImport(imp.spec))) {
326
+ red('close-import-unparsed', at, `${basename(scriptPath)}: "${imp.raw}" is not a plain named import this bundle can verify — a default/namespace/side-effect/dynamic import, or a relative import outside src/ (close/ ships only the named script, so a sibling file would be missing from the bundle)`);
327
+ continue;
328
+ }
329
+ // isPlainNamedClause guarantees clause is a non-null `{ ... }` string here
330
+ const names = /** @type {string} */ (imp.clause).slice(1, -1).split(',')
331
+ .map((raw) => raw.trim().split(/\s+as\s+/)[0].trim())
332
+ .filter(Boolean);
333
+ for (const name of names) {
334
+ if (!exportNames.has(name)) {
335
+ red('close-import-unexported', at, `${basename(scriptPath)} imports "${name}" from bareloop's src, which src/index.js does not export`);
336
+ }
337
+ }
338
+ }
339
+ for (const lit of absolutePathLiteralsOf(source)) {
340
+ red('close-absolute-path', at, `${basename(scriptPath)}: bakes in the absolute path "${lit}" — a close judges the cwd the runner gives it, never a path baked into the script (F8/F129)`);
341
+ }
342
+ // PRD item 27/M2 — the spec's OWN sha256 (if it carries one) must agree
343
+ // with the bytes actually being packed into the bundle: the two
344
+ // signatures (this one, and `bundleHash`'s manifest hash over the
345
+ // rewritten script) cover the same bytes by two different paths, and N4
346
+ // is exactly the hazard of letting a spec's signature drift from what it
347
+ // names. A spec with no sha256 at all is a SEPARATE red (`validateJob`'s
348
+ // `missing-required`, upstream of export) — this only fires when a value
349
+ // is present and wrong.
350
+ if (isNonEmptyString(stage.sha256)) {
351
+ const actual = hashCloseScriptBytes(source);
352
+ if (actual !== stage.sha256) {
353
+ red('close-sha-mismatch', at, `${basename(scriptPath)}: signed sha256 ${stage.sha256.slice(0, 12)}… does not match the script bytes being packed (${actual.slice(0, 12)}…) — re-sign before exporting`);
354
+ }
355
+ }
356
+ });
357
+
358
+ if (reds.length > 0) return fail();
359
+
360
+ // ── everything validated — now write, in one pass ──────────────────────
361
+ mkdirSync(outDir, { recursive: true });
362
+ const closeDir = join(outDir, 'close');
363
+ mkdirSync(closeDir, { recursive: true });
364
+ const bridgesDir = join(outDir, 'bridges');
365
+ mkdirSync(bridgesDir, { recursive: true });
366
+
367
+ /** @type {Record<string, string>} */
368
+ const files = {};
369
+ // F132 — keyed by scriptPath (not name) so the spec-rewrite pass below can
370
+ // look up each relocated stage's REWRITTEN-bytes hash without re-hashing.
371
+ // Both `sha256` here and `hashCloseScriptBytes` (src/close-integrity.js)
372
+ // hash the same way (sha256 of the UTF-8 script text) — this is not a
373
+ // second formula, just this module's own existing helper reused.
374
+ /** @type {Map<string, string>} scriptPath -> sha256 of the RELOCATED bytes */
375
+ const relocatedSha = new Map();
376
+ for (const [scriptPath, name] of byPath) {
377
+ const rewritten = rewriteSrcImports(scripts[scriptPath]);
378
+ writeFileSync(join(closeDir, name), rewritten);
379
+ const hash = sha256(rewritten);
380
+ files[`close/${name}`] = hash;
381
+ relocatedSha.set(scriptPath, hash);
382
+ }
383
+
384
+ const specOut = { ...spec, close: spec.close.map((/** @type {any} */ stage) => {
385
+ const parsed = parseNodeCmd(stage.cmd);
386
+ // unreachable: every stage's cmd already passed this same parse above, or
387
+ // the function returned before reaching this write pass at all
388
+ if (!parsed.ok) throw new Error(`unreachable: ${stage.cmd} was validated as a relocatable close cmd`);
389
+ const name = byPath.get(parsed.scriptPath);
390
+ const cmd = ['node', `${BUNDLE_TOKEN}/close/${name}`, ...parsed.rest].join(' ');
391
+ // F132 (run mtqwmb9l/mtqwydl4): the bundle's manifest hash covers the
392
+ // RELOCATED bytes (the import rewrite above), but the spec's own
393
+ // `close[].sha256` was left carrying the SOURCE spec's signature — two
394
+ // signatures over two different byte strings, so `checkCloseByteSignature`
395
+ // at run start correctly read the bundle as tampered even though nothing
396
+ // was. Re-sign here, over the bytes actually being packed, so the two
397
+ // signatures agree by construction. Only a stage that already carried a
398
+ // signature gets one written back — a stage export never fabricates a
399
+ // signature that was never there (M2 leaves that to the validator).
400
+ const sha256Out = typeof stage.sha256 === 'string' ? (relocatedSha.get(parsed.scriptPath) ?? stage.sha256) : stage.sha256;
401
+ return { ...stage, cmd, sha256: sha256Out };
402
+ }) };
403
+ const specText = `${JSON.stringify(specOut, null, 2)}\n`;
404
+ writeFileSync(join(outDir, 'spec.json'), specText);
405
+ files['spec.json'] = sha256(specText);
406
+
407
+ // Bundle EVERY candidate bridge that exists (base and/or shape fork) — the
408
+ // spec says "the job's whole registry history (greens and reds)", not just
409
+ // whichever one happened to carry the matching hash.
410
+ for (const b of candidateBridges) {
411
+ writeFileSync(join(bridgesDir, `${b.name}.json`), `${JSON.stringify(b, null, 2)}\n`);
412
+ }
413
+
414
+ const hash = hashFilesMap(files);
415
+ const manifest = {
416
+ schema: BUNDLE_SCHEMA,
417
+ job: spec.job,
418
+ exportedAt: new Date().toISOString(),
419
+ bareloopVersion,
420
+ files,
421
+ bundleHash: hash,
422
+ };
423
+ writeFileSync(join(outDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
424
+
425
+ // `package.json` — required by the frozen spec's bundle table so `npm install
426
+ // <bundle dir>` (validation step 2) works in a clean consumer. Deliberately
427
+ // OUTSIDE `bundleHash`: the hash covers spec.json + close/* only (per spec),
428
+ // so this file is written after `hashFilesMap(files)` above and never folded
429
+ // into `files`/`manifest.files`.
430
+ const npmName = `${spec.job}.bareloop`.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
431
+ const packageJson = {
432
+ name: npmName,
433
+ version: '1.0.0',
434
+ private: true,
435
+ type: 'module',
436
+ dependencies: { bareloop: `^${bareloopVersion}` },
437
+ bareloop: { manifest: 'manifest.json' },
438
+ };
439
+ writeFileSync(join(outDir, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`);
440
+
441
+ const readme = [
442
+ `# ${spec.job}`,
443
+ '',
444
+ spec.goal ? spec.goal : '',
445
+ '',
446
+ `bundleHash: ${hash}`,
447
+ `exported: ${manifest.exportedAt}`,
448
+ `bareloop: ${bareloopVersion}`,
449
+ '',
450
+ '## Install this bundle\'s own dependency first (F128)',
451
+ '',
452
+ ' cd <this directory> && npm install',
453
+ '',
454
+ 'This bundle\'s own package.json declares "bareloop" as its dependency — its close',
455
+ 'scripts import from it at run time. `npm install <this directory>` from SOMEWHERE',
456
+ 'ELSE installs this bundle AS that other project\'s dependency instead, which never',
457
+ 'installs this bundle\'s own node_modules and crashes every close stage. Run npm',
458
+ 'install INSIDE this directory, then use the bareloop binary it just installed:',
459
+ '',
460
+ '## Running this bundle',
461
+ '',
462
+ ' ./node_modules/.bin/bareloop run . --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]',
463
+ '',
464
+ 'Answer these, in the order `bareloop run` asks them:',
465
+ '',
466
+ '1. Provider key — set ANTHROPIC_API_KEY in your environment (never on the',
467
+ ' command line, never in a file). Absent, `bareloop run` prints these',
468
+ ' questions and the bundleHash again, and spends nothing.',
469
+ `2. Approve — the FIRST run on this machine requires --approve ${hash}`,
470
+ " (this bundle's own signed hash, shown above) to prove a human read and",
471
+ ' accepted it before anything runs. Once that first run GREENS, the',
472
+ ' bundle is blessed and later runs need no re-approval ("no-resign") as',
473
+ ' long as it stays unchanged.',
474
+ '3. Repo — --repo <path> must be a git repository with at least one commit.',
475
+ ' Every run creates a FRESH detached worktree under',
476
+ ' <repo>/.bareloop/wt/<runid> and never reuses or deletes one for you;',
477
+ ' your checkout is never touched.',
478
+ '4. Budget/wall (optional) — --budget <usd> and --wall <minutes> may only',
479
+ ` TIGHTEN this bundle's own signed ceiling${spec.maxWallMs != null ? 's' : ''} (budgetUsd ${spec.budgetUsd ?? '?'}`
480
+ + `${spec.maxWallMs != null ? `, maxWallMs ${spec.maxWallMs}` : ''}); raising either is a`,
481
+ ' re-export, never a runner flag.',
482
+ '',
483
+ ].join('\n');
484
+ writeFileSync(join(outDir, 'README.md'), readme);
485
+
486
+ return { ok: true, reds: [], dir: outDir, bundleHash: hash, manifest };
487
+ }
488
+
489
+ /** @param {string} file @returns {{ ok: true, value: any } | { ok: false, error: string }} */
490
+ function readJsonFile(file) {
491
+ let text;
492
+ try { text = readFileSync(file, 'utf8'); } catch (err) {
493
+ return { ok: false, error: String(/** @type {Error} */ (err).message) };
494
+ }
495
+ try { return { ok: true, value: JSON.parse(text) }; } catch (err) {
496
+ return { ok: false, error: String(/** @type {Error} */ (err).message) };
497
+ }
498
+ }
499
+
500
+ /**
501
+ * READ a bundle directory back: the manifest/spec, tamper-checked against
502
+ * what is actually on disk, plus the record-only bridges/blessing/history a
503
+ * runner needs beside them. Never throws — every failure is a typed red.
504
+ *
505
+ * `bundle-tampered` (N4) is a RED, never a warning, and runs whenever both a
506
+ * manifest and a spec could be read at all — a swapped close script must be
507
+ * caught here, before any caller reaches `runJob`.
508
+ * @param {string} dir bundle directory
509
+ * @returns {{ ok: boolean, reds: Red[], spec: any, manifest: any, bridges: any[], blessing: any, history: any[] }}
510
+ */
511
+ export function readBundle(dir) {
512
+ /** @type {Red[]} */
513
+ const reds = [];
514
+ const red = (/** @type {string} */ code, /** @type {string} */ path, /** @type {string} */ detail) => reds.push({ code, path, detail });
515
+
516
+ if (!isDir(dir)) {
517
+ red('bundle-missing', dir, 'not a directory');
518
+ return { ok: false, reds, spec: null, manifest: null, bridges: [], blessing: null, history: [] };
519
+ }
520
+
521
+ const manifestR = readJsonFile(join(dir, 'manifest.json'));
522
+ if (!manifestR.ok) red('manifest-invalid', 'manifest.json', manifestR.error);
523
+ const specR = readJsonFile(join(dir, 'spec.json'));
524
+ if (!specR.ok) red('spec-invalid', 'spec.json', specR.error);
525
+
526
+ if (manifestR.ok && specR.ok) {
527
+ let recomputed = null;
528
+ try { recomputed = bundleHash(dir); } catch { recomputed = null; }
529
+ if (recomputed === null || recomputed !== manifestR.value.bundleHash) {
530
+ red('bundle-tampered', 'manifest.bundleHash', `stored ${manifestR.value.bundleHash ?? '<none>'} vs recomputed ${recomputed ?? '<unreadable>'}`);
531
+ }
532
+ }
533
+
534
+ /** @type {any[]} */
535
+ const bridges = [];
536
+ const bridgesDir = join(dir, 'bridges');
537
+ if (isDir(bridgesDir)) {
538
+ for (const f of readdirSync(bridgesDir).filter((n) => n.endsWith('.json')).sort()) {
539
+ const r = readJsonFile(join(bridgesDir, f));
540
+ if (r.ok) bridges.push(r.value);
541
+ }
542
+ }
543
+
544
+ const blessingR = readJsonFile(join(dir, 'blessing.json'));
545
+
546
+ /** @type {any[]} */
547
+ const history = [];
548
+ const historyFile = join(dir, 'history.jsonl');
549
+ if (existsSync(historyFile)) {
550
+ for (const line of readFileSync(historyFile, 'utf8').split('\n')) {
551
+ if (!line.trim()) continue;
552
+ try { history.push(JSON.parse(line)); } catch { /* a malformed line is skipped, not fatal to the read */ }
553
+ }
554
+ }
555
+
556
+ return {
557
+ ok: reds.length === 0,
558
+ reds,
559
+ spec: specR.ok ? specR.value : null,
560
+ manifest: manifestR.ok ? manifestR.value : null,
561
+ bridges,
562
+ blessing: blessingR.ok ? blessingR.value : null,
563
+ history,
564
+ };
565
+ }
566
+
567
+ /**
568
+ * The IN-MEMORY `$BARELOOP_BUNDLE` substitution (the frozen spec's "two
569
+ * facts": the runner never touches the spec on disk, it substitutes the
570
+ * absolute bundle directory into a copy before `runJob`, and signs THAT
571
+ * substituted spec's hash — not the on-disk one). `approveHash` is the value
572
+ * an `--approve` flag / a `history.jsonl` row must record.
573
+ * @param {{ spec: any }} bundle a `readBundle` result (or anything carrying `.spec`)
574
+ * @param {string} bundleDir absolute or relative bundle directory
575
+ * @returns {{ spec: any, approveHash: string }}
576
+ */
577
+ export function resolveBundleSpec(bundle, bundleDir) {
578
+ const absDir = resolve(bundleDir);
579
+ const spec = JSON.parse(JSON.stringify(bundle.spec));
580
+ if (Array.isArray(spec.close)) {
581
+ spec.close = spec.close.map((/** @type {any} */ stage) => (
582
+ isObj(stage) && typeof stage.cmd === 'string'
583
+ ? { ...stage, cmd: stage.cmd.split(BUNDLE_TOKEN).join(absDir) }
584
+ : stage
585
+ ));
586
+ }
587
+ return { spec, approveHash: jobSpecHash(spec) };
588
+ }
589
+
590
+ /**
591
+ * TIGHTEN-ONLY envelope check (hamr Q8: raising the ceiling is a re-export,
592
+ * never a runner flag). Each GIVEN number must be ≤ the bundle spec's own —
593
+ * an absent field in the envelope trivially passes (nothing was asked to
594
+ * tighten), and a spec with no ceiling of its own for that field (only
595
+ * `maxWallMs` is ever absent — `budgetUsd` is required) has nothing to
596
+ * widen, so a given number passes there too.
597
+ * @param {any} spec the (already `$BARELOOP_BUNDLE`-resolved) bundle spec
598
+ * @param {{ budgetUsd?: number, maxWallMs?: number }} envelope
599
+ * @returns {{ ok: boolean, reds: Red[] }}
600
+ */
601
+ export function checkEnvelope(spec, envelope = {}) {
602
+ /** @type {Red[]} */
603
+ const reds = [];
604
+ const s = isObj(spec) ? spec : {};
605
+ const e = isObj(envelope) ? envelope : {};
606
+ for (const field of /** @type {const} */ (['budgetUsd', 'maxWallMs'])) {
607
+ if (e[field] === undefined) continue;
608
+ if (typeof e[field] !== 'number' || !Number.isFinite(e[field]) || e[field] <= 0) {
609
+ reds.push({ code: 'invalid-value', path: field, detail: 'a positive number' });
610
+ continue;
611
+ }
612
+ if (typeof s[field] === 'number' && e[field] > s[field]) {
613
+ reds.push({ code: 'envelope-widen', path: field, detail: `${e[field]} exceeds the bundle's signed ${field} (${s[field]}) — raising a ceiling is a re-export, never a runner flag` });
614
+ }
615
+ }
616
+ return { ok: reds.length === 0, reds };
617
+ }
618
+
619
+ /**
620
+ * Write `blessing.json`: the FIRST GREEN RUN on the importer's own machine
621
+ * signs the bundle as trustworthy (hamr Q2), so later runs need no
622
+ * `--approve` (hamr: "no-resign"). Overwrites any prior blessing — there is
623
+ * only ever one, for the bundle's current `bundleHash`.
624
+ * @param {string} dir bundle directory
625
+ * @param {{ bundleHash: string, runid: string, outcome: string, host?: string, blessedAt?: string }} record
626
+ * @returns {any} the blessing object written
627
+ */
628
+ export function bless(dir, record) {
629
+ const o = isObj(record) ? record : {};
630
+ const blessing = {
631
+ bundleHash: o.bundleHash,
632
+ blessedAt: o.blessedAt ?? new Date().toISOString(),
633
+ runid: o.runid ?? null,
634
+ outcome: o.outcome ?? null,
635
+ host: o.host ?? null,
636
+ };
637
+ writeFileSync(join(dir, 'blessing.json'), `${JSON.stringify(blessing, null, 2)}\n`);
638
+ return blessing;
639
+ }
640
+
641
+ /**
642
+ * Read `blessing.json` back and judge it against the bundle's CURRENT
643
+ * manifest hash. Three outcomes, never conflated: no blessing at all
644
+ * (`unblessed: true` — the bundle has simply never greened here, not a red);
645
+ * a blessing whose hash no longer matches the manifest (`blessing-stale` —
646
+ * the bundle was re-exported/edited since the run that blessed it); a match.
647
+ * @param {{ manifest: any, blessing: any }} bundle a `readBundle` result
648
+ * @returns {{ ok: boolean, unblessed: boolean, reds: Red[] }}
649
+ */
650
+ export function verifyBlessing(bundle) {
651
+ const b = isObj(bundle) ? bundle : {};
652
+ if (!isObj(b.blessing)) return { ok: false, unblessed: true, reds: [] };
653
+ const manifestHash = isObj(b.manifest) ? b.manifest.bundleHash : undefined;
654
+ if (b.blessing.bundleHash !== manifestHash) {
655
+ return { ok: false, unblessed: false, reds: [{ code: 'blessing-stale', path: 'blessing.bundleHash', detail: `blessed ${b.blessing.bundleHash ?? '<none>'} vs manifest ${manifestHash ?? '<none>'} — re-export and re-approve` }] };
656
+ }
657
+ return { ok: true, unblessed: false, reds: [] };
658
+ }
659
+
660
+ /**
661
+ * F128 preflight — can this bundle's close scripts actually resolve `import
662
+ * ... from 'bareloop'` at runtime? The bundle's own `package.json` declares
663
+ * `bareloop` as ITS dependency (`npm install` must be run INSIDE the bundle
664
+ * directory — validation step 2 was corrected 2026-09-06, see the dated
665
+ * addendum in `docs/product/EXPORT-BUILD.md`); nothing installs it for the
666
+ * bundle as a side effect of the bundle being installed as someone else's
667
+ * dependency. Left unchecked, a missing `node_modules` crashes every close
668
+ * stage deep inside the close-first precheck with a bare
669
+ * `ERR_MODULE_NOT_FOUND`, which surfaces as a generic `close-red` with no
670
+ * cure line (the live defect this fixes) — this check runs first and fails
671
+ * fast with the exact command to run.
672
+ *
673
+ * Resolves the bare specifier `'bareloop'` exactly as a close script would,
674
+ * from inside the bundle's own `close/` directory (the probe path itself
675
+ * need not exist — `createRequire`/`resolve` only use it to anchor the
676
+ * `node_modules` walk). Going through `require.resolve` also walks
677
+ * `bareloop`'s own `exports` map (the package root, `"."` -> `./src/
678
+ * index.js`), so a bare successful resolve already proves a real,
679
+ * exports-map-shaped `bareloop` package is reachable from THIS bundle's own
680
+ * dependency chain — not some unrelated global install. Any resolution
681
+ * counts (a direct `node_modules/bareloop`, a symlink into it, or a parent
682
+ * `node_modules/bareloop` up the directory chain) — it only has to resolve.
683
+ * @param {string} bundleDir
684
+ * @returns {{ ok: boolean, reds: Red[] }}
685
+ */
686
+ export function checkBundleDeps(bundleDir) {
687
+ /** @type {Red[]} */
688
+ const reds = [];
689
+ const probeFrom = join(bundleDir, 'close', '__bundle_deps_probe__.mjs');
690
+ try {
691
+ createRequire(probeFrom).resolve('bareloop');
692
+ } catch {
693
+ reds.push({
694
+ code: 'bundle-deps-missing',
695
+ path: bundleDir,
696
+ detail: `close scripts cannot resolve their own "bareloop" dependency — run: cd ${bundleDir} && npm install`,
697
+ });
698
+ return { ok: false, reds };
699
+ }
700
+ return { ok: true, reds: [] };
701
+ }
702
+
703
+ /**
704
+ * Append one row to `history.jsonl` — one `bareloop run` on this machine per
705
+ * line. Same convention as `spine.js`/`ledger.js`: `appendFileSync` opens
706
+ * with O_APPEND, so one line under PIPE_BUF is written atomically with
707
+ * respect to other appenders; there is no read-modify-write here to race.
708
+ * @param {string} dir bundle directory
709
+ * @param {any} row
710
+ */
711
+ export function appendHistory(dir, row) {
712
+ appendFileSync(join(dir, 'history.jsonl'), `${JSON.stringify(row)}\n`);
713
+ }