bareloop 0.23.0 → 0.25.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/source.js ADDED
@@ -0,0 +1,849 @@
1
+ // The source front door (PRD item 33/M2, `docs/product/ITEM33-BUILD.md`) — a
2
+ // plain folder, a plain file, or one URL the machine can reach, frozen into a
3
+ // hidden git tree BEFORE any token spends, so the run and the close judge the
4
+ // SAME bytes (hamr's ruling, PRD item 33, 2026-09-10). H1's fix: the person
5
+ // never sees the git repo — it is scratch plumbing this module owns, not a
6
+ // `--patient` a job author writes by hand.
7
+ //
8
+ // Three functions, three moments in a job's life:
9
+ // - `prepareSource` — BUILD the frozen tree, once, at prep time ($0, no
10
+ // provider). This is where H1 (a plain folder needs no repo) and H7 (PDF
11
+ // and Word input have no reader here — logged, not built) actually live.
12
+ // - `proveDestination` — PROVE the drop-off point is writable, at $0,
13
+ // before any token spends (a job that cannot land its output should
14
+ // never buy a worker turn first).
15
+ // - `copyOut` — COPY the run's own output to that drop-off point, once,
16
+ // on a minted green (never a red, never a stop — this module makes no
17
+ // verdict and changes none).
18
+ //
19
+ // Every failure here is a NAMED refusal, never a throw and never a silent
20
+ // fallback: `{stop: null, ...}` on success, `{stop: <message>, code: <name>}`
21
+ // on refusal — the same discriminated shape `seedAtHead`/`seedListing`
22
+ // (src/kinds.js) already use for a $0 precheck a runner has to print and act
23
+ // on rather than catch.
24
+
25
+ import { createHash } from 'node:crypto';
26
+ import { existsSync } from 'node:fs';
27
+ import { mkdir, readdir, readFile, writeFile, lstat, stat, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
28
+ import { dirname, join, resolve, sep, basename } from 'node:path';
29
+ import { git } from './kinds.js';
30
+ import { MAX_BUFFER } from './kinds.js';
31
+ import { PROVIDER_TIMEOUT_MS } from './clock.js';
32
+ import { scanSecrets, SECRET_PATTERNS, SECRET_PATTERN_NAMES } from './validate.js';
33
+
34
+ /** @typedef {{stop: string, code: string}} SourceRefusal a named, non-throwing stop */
35
+
36
+ /** the seed commit's identity — NEVER the operator's, NEVER global config
37
+ * (CI has no gitconfig, and this repo never touches one either way): the
38
+ * hidden tree is bareloop's own scratch plumbing, so its one commit is
39
+ * authored by bareloop, not by whoever happens to run it. `commit.gpgsign`
40
+ * is pinned off for the same reason a global config is never read: a
41
+ * machine with `commit.gpgsign=true` in ITS OWN global config would
42
+ * otherwise make this $0 step fail on a signing key that has nothing to do
43
+ * with the job. */
44
+ const GIT_IDENTITY = ['-c', 'user.name=bareloop', '-c', 'user.email=bareloop@localhost', '-c', 'commit.gpgsign=false'];
45
+
46
+ /** a live-proven defect (found reviewing M2b fix 7): a copied `.git/hooks`
47
+ * carries the SOURCE repo's own scripts, and `git commit` runs them —
48
+ * `pre-commit`/`commit-msg`/`post-commit` are arbitrary code, and copying a
49
+ * repo source would otherwise execute whatever the original author put
50
+ * there, inside bareloop's own process, before a single token spends. The
51
+ * hooks directory is stripped after the `.git` copy (below) AND every git
52
+ * call this door makes pins `core.hooksPath` to a path that is never
53
+ * created, so a `core.hooksPath` set in the copied `.git/config` to point
54
+ * somewhere else on disk cannot resurrect the hole either. Belt and braces,
55
+ * not redundant: either alone leaves a way back in. */
56
+ const NO_HOOKS = ['-c', 'core.hooksPath=/dev/null/bareloop-no-hooks'];
57
+
58
+ /** content-types this door accepts for a URL source (hamr: "start with
59
+ * already supported files") — `text/*` covers `text/csv` already; it is kept
60
+ * spelled out because the PRD ruling names it explicitly and a reader
61
+ * checking this list against that ruling should find it there verbatim. */
62
+ const TEXT_CONTENT_TYPES = [/^text\//, /^application\/json/, /^application\/xml/];
63
+
64
+ /** an environment file, refused by NAME whatever it contains (hamr's ruling,
65
+ * PRD item 33/M2b fix 2). The content scan (`scanFilesForSecrets`) catches a
66
+ * key it KNOWS the shape of; this catches the file people keep keys in even
67
+ * when the shape is one nobody inventoried yet — the two are belt and braces,
68
+ * not duplicates. */
69
+ const ENV_FILE = /^\.env($|\.)/;
70
+
71
+ /** how many same-day deliveries one destination name can take before the door
72
+ * gives up (`profile-2026-09-12.md`, `-2`, … `-99`). A cap, never a silent
73
+ * overwrite: past it, `destination-exists`. */
74
+ const MAX_SAME_DAY = 99;
75
+
76
+ /** @param {string} code @param {string} stop @returns {SourceRefusal} */
77
+ const refuse = (code, stop) => ({ stop, code });
78
+
79
+ /** @param {Buffer} buf @returns {boolean} */
80
+ function hasNulByte(buf) {
81
+ // "first 8 KB" (PRD/M2 text) — a NUL anywhere further in a huge log-shaped
82
+ // file is not this door's problem; the point is a cheap, bounded sniff, not
83
+ // a full-file binary detector.
84
+ const n = Math.min(buf.length, 8192);
85
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Sniff a file's first 8KB for a NUL byte WITHOUT reading the whole file — a
91
+ * folder source may legally carry a file right up against `MAX_BUFFER`, and
92
+ * reading it twice (once to sniff, once to freeze) doubles the cost of the
93
+ * cheapest check here for nothing.
94
+ * @param {string} path
95
+ * @returns {Promise<boolean>}
96
+ */
97
+ async function sniffNul(path) {
98
+ const fh = await open(path, 'r');
99
+ try {
100
+ const buf = Buffer.alloc(8192);
101
+ const { bytesRead } = await fh.read(buf, 0, 8192, 0);
102
+ return hasNulByte(buf.subarray(0, bytesRead));
103
+ } finally {
104
+ await fh.close();
105
+ }
106
+ }
107
+
108
+ /** @param {Buffer} buf @returns {string} */
109
+ const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex');
110
+
111
+ /**
112
+ * Scan every frozen file's CONTENT for a known secret shape, using the SAME
113
+ * inventory `scanSecrets` reads (`SECRET_PATTERNS`, src/validate.js) — never
114
+ * a second, hand-typed pattern list. This is the fix for the live-proven
115
+ * defect: the front door scanned only the URL string, so a plain folder or
116
+ * file carrying a real API key (an `.env`, a checked-in credential) was
117
+ * frozen into the tree and committed to the hidden git seed untouched.
118
+ *
119
+ * Names the file(s) and the PATTERN NAME(S) only — computed by testing each
120
+ * pattern in isolation (never by reading `scanSecrets`' own matched text),
121
+ * so the actual secret substring never has to exist in a variable that could
122
+ * leak into a refusal, a manifest, stdout, or a log. Whole-file content is
123
+ * decoded and tested, never a sampled prefix (the NUL sniff samples 8KB; a
124
+ * secret check must not — a key can sit anywhere in a large file).
125
+ * @param {string} text one file's decoded content
126
+ * @returns {string[]} the names of every pattern that hit, empty when none did
127
+ */
128
+ function secretPatternNames(text) {
129
+ return SECRET_PATTERN_NAMES.filter((_, i) => SECRET_PATTERNS[i].test(text));
130
+ }
131
+
132
+ /**
133
+ * Walk a plain folder recursively, refusing on the first symlink, nested
134
+ * repo, environment file, oversize file, or binary file it finds. Returns
135
+ * every REGULAR file, relative to `root`. This is the PLAIN-FOLDER path only
136
+ * — a repo source is enumerated by `listRepoFiles` instead (D1 rework,
137
+ * `docs/product/ITEM33-BUILD.md`), which reads git's own tracked-file list
138
+ * rather than walking the filesystem, so nothing gitignored or untracked
139
+ * (npm's `node_modules/.bin/*` symlinks included) is ever a candidate here.
140
+ *
141
+ * A symlink is named and never followed (PRD ruling) — walking into it would
142
+ * silently widen the frozen copy to whatever the link points at, which nobody
143
+ * signed. A `.git` anywhere below the root is a NESTED repo (M2b fix 6): its
144
+ * objects are a second history nobody declared, and `git add` would either
145
+ * swallow it as a gitlink or drop its files entirely, so the seed would not
146
+ * hold what the manifest says it holds.
147
+ * @param {string} root
148
+ * @returns {Promise<{stop: null, files: string[]}|SourceRefusal>}
149
+ */
150
+ async function walkFolder(root) {
151
+ /** @type {string[]} */
152
+ const files = [];
153
+ /** @type {string[]} */
154
+ const notText = [];
155
+ /** @type {string[]} */
156
+ const oversize = [];
157
+ /** @param {string} dir @param {string} rel */
158
+ async function walk(dir, rel) {
159
+ const entries = await readdir(dir, { withFileTypes: true });
160
+ for (const e of entries) {
161
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
162
+ if (e.isSymbolicLink()) return refuse('source-symlink', `${relPath} is a symlink — named, never followed (a followed link would silently widen the frozen copy to whatever it points at)`);
163
+ if (e.name === '.git') {
164
+ return refuse('source-nested-repo', `${relPath} is a nested git repo — refused, because the seed commit cannot hold a second history honestly (the files under it would be swallowed as a gitlink or dropped outright, and the manifest would then claim bytes the seed does not carry)`);
165
+ }
166
+ if (ENV_FILE.test(e.name)) {
167
+ return refuse('source-env-file', `${relPath} is an environment file — refused by NAME, whatever it contains (hard line: secrets never enter the tree)`);
168
+ }
169
+ if (e.isDirectory()) {
170
+ const sub = await walk(join(dir, e.name), relPath);
171
+ if (sub) return sub;
172
+ } else if (e.isFile()) {
173
+ const st = await stat(join(dir, e.name));
174
+ if (st.size > MAX_BUFFER) { oversize.push(`${relPath} (${st.size}B)`); continue; }
175
+ if (await sniffNul(join(dir, e.name))) { notText.push(relPath); continue; }
176
+ files.push(relPath);
177
+ }
178
+ // neither file, dir, nor symlink (a socket, a fifo) — silently skipped;
179
+ // nothing this door reads or writes can be one
180
+ }
181
+ return null;
182
+ }
183
+ const walkStop = await walk(root, '');
184
+ if (walkStop) return walkStop;
185
+ if (oversize.length) {
186
+ return refuse('source-file-oversize', `${oversize.length} file(s) are over the ${MAX_BUFFER}B per-file ceiling — a source door freezes what a run can actually read, never a file it would only ever see a truncated prefix of: ${oversize.join(', ')}`);
187
+ }
188
+ if (notText.length) {
189
+ return refuse('source-not-text', `${notText.length} file(s) carry a NUL byte in their first 8KB — text only for now (hole H7: PDF/Word input needs a reader this door does not have): ${notText.join(', ')}`);
190
+ }
191
+ return { stop: null, files };
192
+ }
193
+
194
+ /**
195
+ * Enumerate a REPO source's files the D1 way (hamr's ruling, verbatim:
196
+ * "copy only what git tracks"): `git ls-files --stage` in the source, never a
197
+ * filesystem walk — closes F164 (npm's `node_modules/.bin/*` symlinks are
198
+ * never candidates at all, because they are never tracked) and F165 (nothing
199
+ * gitignored or untracked can reach this list, so nothing gitignored can
200
+ * reach the tree or the seed no matter what `git add` flag runs later).
201
+ *
202
+ * Each tracked path's WORKING-TREE content is what gets frozen (so
203
+ * uncommitted edits to a tracked file come along) — this function only
204
+ * enumerates and classifies; the caller reads bytes.
205
+ *
206
+ * A gitlink entry (mode `160000`, a real submodule) is refused
207
+ * `source-nested-repo` — it names a second history the seed cannot hold
208
+ * honestly, the same reason a nested `.git` is refused for a plain folder.
209
+ * A symlink entry (mode `120000`) is refused `source-symlink` ONLY when it
210
+ * resolves outside `root` — one that stays inside is legal tracked content
211
+ * and is copied verbatim as a link (never dereferenced).
212
+ * @param {string} root the repo's working directory (its `.git` is handled
213
+ * separately by the caller, which copies it verbatim)
214
+ * @returns {Promise<{stop: null, files: {rel: string, abs?: string, symlinkTarget?: string}[]}|SourceRefusal>}
215
+ */
216
+ async function listRepoFiles(root) {
217
+ const ls = await git(root, ['ls-files', '-z', '--stage']);
218
+ if (!ls.ok) return refuse('source-git-failed', `git ls-files failed in ${root}: ${ls.err}`);
219
+ const entries = ls.out.split('\0').filter(Boolean);
220
+ /** @type {{rel: string, abs?: string, symlinkTarget?: string}[]} */
221
+ const files = [];
222
+ const rootReal = await realpath(root);
223
+ for (const entry of entries) {
224
+ // "<mode> <sha> <stage>\t<path>" — the mode tells a gitlink (submodule)
225
+ // and a symlink apart from an ordinary tracked file without ever reading
226
+ // bytes for it.
227
+ const tab = entry.indexOf('\t');
228
+ if (tab === -1) continue;
229
+ const mode = entry.slice(0, tab).split(' ')[0];
230
+ const rel = entry.slice(tab + 1);
231
+ if (mode === '160000') {
232
+ return refuse('source-nested-repo', `${rel} is a submodule (a gitlink entry) — refused, the same reason a nested .git is: a second history the seed commit cannot hold honestly`);
233
+ }
234
+ if (ENV_FILE.test(basename(rel))) {
235
+ return refuse('source-env-file', `${rel} is an environment file — refused by NAME, whatever it contains (hard line: secrets never enter the tree)`);
236
+ }
237
+ if (mode === '120000') {
238
+ let target;
239
+ try { target = await readlink(join(root, rel)); } catch (e) {
240
+ return refuse('source-unreadable', `${rel}: tracked symlink could not be read (${/** @type {Error} */ (e)?.message ?? e})`);
241
+ }
242
+ let resolved = null;
243
+ try { resolved = await realpath(join(root, rel)); } catch { /* a dangling link resolves to nothing — treated as outside, below */ }
244
+ if (resolved === null || !(resolved === rootReal || resolved.startsWith(`${rootReal}${sep}`))) {
245
+ return refuse('source-symlink', `${rel} is a symlink resolving outside ${root} — refused (a link leaving the source root would silently widen the frozen copy to whatever it points at); a link that stays inside the root is legal tracked content and is copied verbatim`);
246
+ }
247
+ files.push({ rel, symlinkTarget: target });
248
+ } else {
249
+ files.push({ rel, abs: join(root, rel) });
250
+ }
251
+ }
252
+ return { stop: null, files };
253
+ }
254
+
255
+ /**
256
+ * Fetch a URL source ONCE — plain `fetch`, no credentials, no cookies, no
257
+ * custom headers — under an injectable deadline (production default
258
+ * `PROVIDER_TIMEOUT_MS`, reused from `src/clock.js` rather than a second
259
+ * number; a test injects a short one so a silent-server case does not cost
260
+ * real minutes). The body is read as it streams so an oversize response is
261
+ * caught at `MAX_BUFFER` (`src/kinds.js`, reused) WITHOUT ever buffering more
262
+ * than the ceiling — a `response.text()` on a large body would have already
263
+ * paid the memory cost this exists to cap.
264
+ * @param {string} url
265
+ * @param {number} timeoutMs
266
+ * Redirects are FOLLOWED but never invisible (M2b fix 5): the URL the body
267
+ * actually came from (`res.url`) rides back out, into the manifest and onto
268
+ * `prep-source`'s stdout, so a link that quietly lands on a login page or a
269
+ * different host is something a person can SEE before a single token spends.
270
+ * @returns {Promise<{stop: null, buf: Buffer, contentType: string, finalUrl: string}|SourceRefusal>}
271
+ */
272
+ async function fetchOnce(url, timeoutMs) {
273
+ const ac = new AbortController();
274
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
275
+ let res;
276
+ try {
277
+ res = await fetch(url, { signal: ac.signal, redirect: 'follow', credentials: 'omit' });
278
+ } catch (e) {
279
+ if (/** @type {any} */ (e)?.name === 'AbortError') {
280
+ return refuse('source-fetch-timeout', `${url} did not answer inside ${timeoutMs}ms — a timeout REFUSES here, it never falls back to a partial or cached read`);
281
+ }
282
+ return refuse('source-fetch-failed', `${url} could not be fetched: ${/** @type {Error} */ (e)?.message ?? String(e)}`);
283
+ } finally {
284
+ clearTimeout(timer);
285
+ }
286
+ if (!res.ok) return refuse('source-fetch-failed', `${url} answered ${res.status} ${res.statusText}`);
287
+ const contentType = (res.headers.get('content-type') ?? '').split(';')[0].trim();
288
+ if (!TEXT_CONTENT_TYPES.some((re) => re.test(contentType))) {
289
+ return refuse('source-not-text', `${url} answered content-type "${contentType || '(none)'}" — text only for now (${TEXT_CONTENT_TYPES.map((r) => r.source).join(', ')})`);
290
+ }
291
+ const reader = res.body?.getReader();
292
+ if (!reader) {
293
+ // a body-less 2xx (204, or a fetch polyfill with no stream) — nothing to
294
+ // freeze, and an empty file would lie about having read something
295
+ return refuse('source-fetch-failed', `${url} answered with no readable body`);
296
+ }
297
+ const chunks = [];
298
+ let total = 0;
299
+ for (;;) {
300
+ const { done, value } = await reader.read();
301
+ if (done) break;
302
+ total += value.length;
303
+ if (total > MAX_BUFFER) {
304
+ try { await reader.cancel(); } catch { /* best-effort — the fetch is being abandoned either way */ }
305
+ return refuse('source-fetch-oversize', `${url} exceeded the ${MAX_BUFFER}B ceiling — the body is truncated, so any content read from it would be unknown, not zero`);
306
+ }
307
+ chunks.push(value);
308
+ }
309
+ return { stop: null, buf: Buffer.concat(chunks), contentType, finalUrl: res.url || url };
310
+ }
311
+
312
+ /**
313
+ * Build the frozen source tree — `<into>/tree/input/…` (the copy), `<into>/tree/output/`
314
+ * (empty, where the run writes), `<into>/source.json` (the manifest, OUTSIDE
315
+ * the tree so no worker can read or edit it). One hidden git commit seeds the
316
+ * tree with a neutralized identity; the seed sha rides in the manifest and is
317
+ * what every close in this run measures against (`seedAtHead`, src/kinds.js).
318
+ *
319
+ * `destination`, when given, is recorded in the manifest (never in a signed
320
+ * job spec — a job spec is a repeatable SHAPE, source/destination a PER-RUN
321
+ * value, see `readSourceManifest`'s own comment) as the run's declared
322
+ * drop-off point. D3 rework (hamr's ruling, 2026-09-12, condensed in
323
+ * `docs/product/ITEM33-BUILD.md`): **destination is a DIRECTORY, never a
324
+ * filename** — it may already exist (need not be empty), and it may sit
325
+ * inside the source itself (the same-dir case: "i can limit your actions to
326
+ * a certain fix in a certain dir and your output will also be there"). A job
327
+ * may produce more than one file; the agent names them, and `copyOut`
328
+ * delivers every non-empty file it finds under `output/`, each under its own
329
+ * dated name. There is therefore no separate `output` field any more — the
330
+ * old single-file `output-invalid` guard existed only to stop a
331
+ * manifest-declared path from escaping the tree, and that vector is closed
332
+ * structurally now: `copyOut` always reads `output/` itself, never a path a
333
+ * manifest could have been hand-edited to name.
334
+ *
335
+ * A `kind: 'repo'` source's `destination` is a DIFFERENT thing entirely
336
+ * (hamr: "destination if for code on a green then that would be the place
337
+ * agent allowed to do changes") — it names the WRITE FENCE inside the copied
338
+ * repo, the signed `writeScope` field's job (`src/job.js:363`), not a
339
+ * filesystem drop-off point this door proves or copies into. It is recorded
340
+ * as declared, unvalidated by `proveDestination`, and `frontDoorFromManifest`
341
+ * never hands a repo source's destination to `copyOut` — wiring it into
342
+ * `writeScope` is M3/M4's job, not this one.
343
+ * @param {{source: string, into: string, destination?: string,
344
+ * fetchTimeoutMs?: number}} args `fetchTimeoutMs` is test-only — production
345
+ * callers omit it and get `PROVIDER_TIMEOUT_MS`.
346
+ * @returns {Promise<{stop: null, into: string, tree: string, manifestPath: string, manifest: object}|SourceRefusal>}
347
+ */
348
+ export async function prepareSource({ source, into, destination, fetchTimeoutMs = PROVIDER_TIMEOUT_MS }) {
349
+ const intoAbs = resolve(into);
350
+ if (existsSync(intoAbs)) {
351
+ return refuse('into-exists', `${intoAbs} already exists — a source door writes a FRESH tree, never reuses one (the export worktree rule)`);
352
+ }
353
+ const isUrl = /^https?:\/\//i.test(source);
354
+ // a CHEAP peek (a stat, never a read) at whether this source is going to
355
+ // turn out to be a repo — needed here, before the source is actually
356
+ // walked below, because a repo source's destination takes a DIFFERENT path
357
+ // (the write-fence recording above) than every other kind's (the directory
358
+ // proof). The real walk below makes the authoritative kind determination
359
+ // (and refuses source-is-linked-worktree when `.git` is a file); this peek
360
+ // only has to agree with it closely enough to route the destination check
361
+ // correctly, and it does — both read the same `.git` existence test.
362
+ let looksLikeRepoSource = false;
363
+ if (!isUrl) {
364
+ try {
365
+ const peekAbs = resolve(source);
366
+ const peekStat = await lstat(peekAbs);
367
+ looksLikeRepoSource = peekStat.isDirectory() && existsSync(join(peekAbs, '.git'));
368
+ } catch { /* an unreadable source is reported properly by the real walk below */ }
369
+ }
370
+ // hamr's ruling (PRD item 33): source AND destination are proven at job
371
+ // start, $0 — and review finding #2 sharpens WHEN: before `into` is
372
+ // created, never after, so a refused destination leaves nothing on disk
373
+ // (no half-built tree for a job that was never going to be able to land
374
+ // its result). Order matters here: this runs before any source is read,
375
+ // fetched, or walked.
376
+ if (destination !== undefined) {
377
+ if (looksLikeRepoSource) {
378
+ if (typeof destination !== 'string' || destination.length === 0) {
379
+ return refuse('destination-invalid', 'destination must be a non-empty value — a repo source records it as the declared write fence, wired into writeScope in a later milestone');
380
+ }
381
+ } else {
382
+ const dp = await proveDestination(destination, { into: intoAbs });
383
+ if (dp.stop !== null) return dp;
384
+ }
385
+ }
386
+
387
+ /** @type {{kind: 'url'|'file'|'folder'|'repo', files: {rel: string, abs?: string, buf?: Buffer, symlinkTarget?: string}[], root?: string, finalUrl?: string}} */
388
+ let frozen;
389
+
390
+ if (isUrl) {
391
+ let u;
392
+ try { u = new URL(source); } catch { return refuse('source-unreadable', `${source} does not parse as a URL`); }
393
+ if (u.username !== '' || u.password !== '') {
394
+ return refuse('source-unreadable', 'no embedded credentials (user:pass@host) — secrets never enter a source door, and a URL is fetched with no credentials at all');
395
+ }
396
+ if (scanSecrets(source).length) {
397
+ return refuse('source-unreadable', 'the URL carries a known secret-token shape — secrets never enter the tree/manifest (hard line #3)');
398
+ }
399
+ const fetched = await fetchOnce(source, fetchTimeoutMs);
400
+ if (fetched.stop !== null) return fetched;
401
+ // the URL the body ACTUALLY came from, after any redirect (M2b fix 5) —
402
+ // scanned the same way the typed one was, because a redirect target is a
403
+ // string this door is about to write into the manifest. Reviewed and
404
+ // deliberately NOT also re-checked for embedded credentials the way the
405
+ // typed URL is: proven live (this session) that `fetch`'s own redirect
406
+ // handling in 'cors' mode (the default this door uses) already returns a
407
+ // network error — `source-fetch-failed` — for any redirect whose target
408
+ // carries a `user:pass@host` userinfo, so a second check here would be
409
+ // unreachable dead code, never provable by a real test.
410
+ if (scanSecrets(fetched.finalUrl).length) {
411
+ return refuse('source-unreadable', 'the URL redirected to a location carrying a known secret-token shape — secrets never enter the tree/manifest (hard line #3)');
412
+ }
413
+ // no reliable filename off a URL in general — the last path segment when
414
+ // there is one, else a fixed name; either way ONE file, never a listing
415
+ const last = new URL(fetched.finalUrl).pathname.split('/').filter(Boolean).at(-1);
416
+ const rel = last || 'source.txt';
417
+ if (ENV_FILE.test(rel)) {
418
+ return refuse('source-env-file', `${fetched.finalUrl} names an environment file — refused by NAME, whatever it contains (hard line: secrets never enter the tree)`);
419
+ }
420
+ frozen = { kind: 'url', files: [{ rel, buf: fetched.buf }], finalUrl: fetched.finalUrl };
421
+ } else {
422
+ const sourceAbs = resolve(source);
423
+ let st;
424
+ try { st = await lstat(sourceAbs); } catch { return refuse('source-unreadable', `${source} does not exist or cannot be read`); }
425
+ if (st.isSymbolicLink()) return refuse('source-symlink', `${source} is a symlink — named, never followed`);
426
+ if (st.isDirectory()) {
427
+ // M2b fix 7 (hamr: yes — the PR-review job): a git repo IS an allowed
428
+ // source now. It is COPIED with its history, never used in place (the
429
+ // patients-are-copies rule), and gets the same `output/`, manifest,
430
+ // destination and copy-out every other source gets. Its own guards
431
+ // (the input stays untouched) arrive with M4.
432
+ const dotGit = join(sourceAbs, '.git');
433
+ let isRepo = false;
434
+ if (existsSync(dotGit)) {
435
+ // a `.git` FILE (a linked worktree or a submodule) points its gitdir
436
+ // somewhere else entirely — copying it would aim every git command,
437
+ // the seed commit included, at the ORIGINAL repo. Refused, never
438
+ // followed, for the same reason a symlink is.
439
+ if (!(await lstat(dotGit)).isDirectory()) {
440
+ return refuse('source-is-linked-worktree', `${source}/.git is a file, not a directory — this is a linked worktree or a submodule, whose real git directory lives elsewhere; point the door at the repo itself (a copy would silently commit into the original)`);
441
+ }
442
+ isRepo = true;
443
+ }
444
+ if (isRepo) {
445
+ const walked = await listRepoFiles(sourceAbs);
446
+ if (walked.stop !== null) return walked;
447
+ frozen = { kind: 'repo', root: sourceAbs, files: walked.files };
448
+ } else {
449
+ const walked = await walkFolder(sourceAbs);
450
+ if (walked.stop !== null) return walked;
451
+ frozen = {
452
+ kind: 'folder',
453
+ root: sourceAbs,
454
+ files: walked.files.map((rel) => ({ rel, abs: join(sourceAbs, rel) })),
455
+ };
456
+ }
457
+ } else if (st.isFile()) {
458
+ const name = /** @type {string} */ (source.split(sep).at(-1) ?? source);
459
+ if (ENV_FILE.test(name)) {
460
+ return refuse('source-env-file', `${source} is an environment file — refused by NAME, whatever it contains (hard line: secrets never enter the tree)`);
461
+ }
462
+ if (st.size > MAX_BUFFER) {
463
+ return refuse('source-file-oversize', `${source} is ${st.size}B, over the ${MAX_BUFFER}B per-file ceiling — a source door freezes what a run can actually read, never a file it would only ever see a truncated prefix of`);
464
+ }
465
+ const buf = await readFile(sourceAbs);
466
+ if (hasNulByte(buf.subarray(0, 8192))) return refuse('source-not-text', `${source} carries a NUL byte in its first 8KB — text only for now (hole H7)`);
467
+ frozen = { kind: 'file', files: [{ rel: name, buf }] };
468
+ } else {
469
+ return refuse('source-unreadable', `${source} is neither a regular file, a folder, nor an http(s) URL`);
470
+ }
471
+ // D3 rework: a destination sitting inside the source is now LEGAL —
472
+ // hamr's same-dir case ("i can limit your actions to a certain fix in a
473
+ // certain dir and your output will also be there"). `destination-in-source`
474
+ // is gone; `copyOut` never overwrites an existing file (dated names,
475
+ // `COPYFILE_EXCL`), so a same-dir destination cannot clobber the source.
476
+ }
477
+
478
+ // a repo source keeps its own shape (the history is the point, and a
479
+ // worker reviewing a PR needs the repo AT the tree root); every other kind
480
+ // is frozen under `input/`, leaving `output/` as the only place a run writes
481
+ const underInput = frozen.kind !== 'repo';
482
+ /** @param {string} rel */
483
+ const treeRel = (rel) => (underInput ? `input/${rel}` : rel);
484
+
485
+ // hard line (CLAUDE.md): secrets never enter the tree, the spine, the
486
+ // configs, or the ledger — an append-only log that captures a key captures
487
+ // it forever. Every frozen file's content is scanned BEFORE anything is
488
+ // written under `into` (no mkdir has run yet — a refusal here leaves
489
+ // nothing on disk, `into` itself included). The matched text is never
490
+ // read into this refusal — only the file path and the pattern name. One
491
+ // file at a time: a repo source can be far larger than memory if every
492
+ // buffer is held at once.
493
+ //
494
+ // D2 (closes the F166 residual): a BINARY file (a NUL byte in its first
495
+ // 8KB) used to be hashed and never scanned — a named hole sitting on a hard
496
+ // line. It now runs through the SAME `SECRET_PATTERNS` inventory, decoded
497
+ // `latin1` rather than `utf8` — `latin1` is byte-preserving (one byte, one
498
+ // code point), so an ASCII key sitting inside a compiled artifact still
499
+ // matches; `utf8` would have thrown or mangled bytes on arbitrary binary
500
+ // content. No second pattern list, ever.
501
+ /** @type {{rel: string, names: string[]}[]} */
502
+ const secretHits = [];
503
+ /** @type {{path: string, bytes: number, sha256: string}[]} */
504
+ const fileMeta = [];
505
+ for (const f of frozen.files) {
506
+ const buf = f.buf ?? (f.symlinkTarget !== undefined ? Buffer.from(f.symlinkTarget, 'utf8') : await readFile(/** @type {string} */ (f.abs)));
507
+ fileMeta.push({ path: f.rel, bytes: buf.length, sha256: sha256Hex(buf) });
508
+ const isBinary = f.symlinkTarget === undefined && hasNulByte(buf.subarray(0, 8192));
509
+ const names = secretPatternNames(buf.toString(isBinary ? 'latin1' : 'utf8'));
510
+ if (names.length) secretHits.push({ rel: f.rel, names });
511
+ }
512
+ if (secretHits.length) {
513
+ const detail = secretHits.map((h) => `${h.rel} (${h.names.join(', ')})`).join('; ');
514
+ return refuse('source-carries-secret', `${secretHits.length} file(s) carry a known secret shape — refused before anything was written (hard line #3, secrets never enter the tree): ${detail}`);
515
+ }
516
+
517
+ const treeDir = join(intoAbs, 'tree');
518
+ const outputDir = join(treeDir, 'output');
519
+ await mkdir(treeDir, { recursive: true });
520
+ if (frozen.kind === 'repo') {
521
+ // the history, copied verbatim — `verbatimSymlinks` so git's own internal
522
+ // links are never dereferenced into copies of what they point at
523
+ await cp(join(/** @type {string} */ (frozen.root), '.git'), join(treeDir, '.git'), { recursive: true, verbatimSymlinks: true });
524
+ // strip the copied hooks (see NO_HOOKS above) — `force: true` because a
525
+ // repo with no hooks configured has no `hooks/` to remove at all
526
+ await rm(join(treeDir, '.git', 'hooks'), { recursive: true, force: true });
527
+ }
528
+ await mkdir(outputDir, { recursive: true });
529
+ // git tracks no empty directories — a `.gitkeep` is what makes `output/`
530
+ // actually exist in the seed commit the close measures changes against
531
+ await writeFile(join(outputDir, '.gitkeep'), '');
532
+ for (const f of frozen.files) {
533
+ const dest = join(treeDir, treeRel(f.rel));
534
+ await mkdir(dirname(dest), { recursive: true });
535
+ if (f.buf) await writeFile(dest, f.buf);
536
+ else if (f.symlinkTarget !== undefined) await symlink(f.symlinkTarget, dest);
537
+ else await copyFile(/** @type {string} */ (f.abs), dest);
538
+ }
539
+
540
+ if (frozen.kind !== 'repo') {
541
+ const init = await git(treeDir, ['init', '-q']);
542
+ if (!init.ok) return refuse('source-git-failed', `git init failed in ${treeDir}: ${init.err}`);
543
+ }
544
+ // `-f` (M2b fix 6) for a PLAIN FOLDER/FILE/URL source only: a `.gitignore`
545
+ // sitting INSIDE such a source must never decide what the seed holds — the
546
+ // hidden git tree has no gitignore of its own yet, so "gitignored" has no
547
+ // honest meaning there. Every file this door froze is a file it promised
548
+ // in the manifest, and a close measures the run's writes against the seed —
549
+ // a file present on disk but absent from the seed would read as the worker
550
+ // having written it.
551
+ //
552
+ // D1 rework (F165): a REPO source drops `-f`. Only tracked files ever reach
553
+ // the copied tree (`listRepoFiles`, above) — nothing gitignored in the
554
+ // SOURCE repo is a candidate to add at all — so a plain `add -A` here adds
555
+ // modifications to already-tracked paths and never force-resurrects
556
+ // anything the source repo chose to ignore (`node_modules`, a gitignored
557
+ // `.env`, this repo's own `.claude/`).
558
+ const addArgs = frozen.kind === 'repo' ? ['add', '-A'] : ['add', '-A', '-f'];
559
+ const add = await git(treeDir, [...NO_HOOKS, ...addArgs]);
560
+ if (!add.ok) return refuse('source-git-failed', `git add failed in ${treeDir}: ${add.err}`);
561
+ const commit = await git(treeDir, [...GIT_IDENTITY, ...NO_HOOKS, 'commit', '-q', '-m', 'bareloop: source front door seed']);
562
+ if (!commit.ok) return refuse('source-git-failed', `git commit failed in ${treeDir}: ${commit.err}`);
563
+ const head = await git(treeDir, ['rev-parse', 'HEAD']);
564
+ if (!head.ok) return refuse('source-git-failed', `git rev-parse HEAD failed in ${treeDir}: ${head.err}`);
565
+ const seed = head.out.trim();
566
+
567
+ // M2b fix 6, the CHECK rather than the hope: the manifest says the seed
568
+ // holds these files, so read the seed back and prove it does. A bare
569
+ // emptiness check would miss partial omission (memory: a completeness guard
570
+ // built on an emptiness check misses exactly this), so the two sets are
571
+ // diffed, both ways, by name.
572
+ const listed = await git(treeDir, ['ls-tree', '-r', '--name-only', 'HEAD']);
573
+ if (!listed.ok) return refuse('source-git-failed', `git ls-tree failed in ${treeDir}: ${listed.err}`);
574
+ const inSeed = new Set(listed.out.split('\n').map((l) => l.trim()).filter(Boolean));
575
+ const missing = fileMeta.map((f) => treeRel(f.path)).filter((p) => !inSeed.has(p));
576
+ if (missing.length) {
577
+ return refuse('source-seed-incomplete', `${missing.length} frozen file(s) are missing from the seed commit — the manifest would claim bytes the seed does not carry, and a close would read them as the worker's own writes: ${missing.join(', ')}`);
578
+ }
579
+
580
+ const manifest = {
581
+ kind: frozen.kind,
582
+ source,
583
+ ...(frozen.finalUrl === undefined ? {} : { finalUrl: frozen.finalUrl }),
584
+ fetchedAt: new Date().toISOString(),
585
+ files: fileMeta,
586
+ seed,
587
+ destination: destination ?? null,
588
+ };
589
+ const manifestPath = join(intoAbs, 'source.json');
590
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
591
+
592
+ return { stop: null, into: intoAbs, tree: treeDir, manifestPath, manifest };
593
+ }
594
+
595
+ /**
596
+ * The delivered name (M2b fix 4, kept under D3): a destination FILE of
597
+ * `/home/me/profile.md` lands as `/home/me/profile-2026-09-12.md`, and a
598
+ * SECOND delivery of the same name the same day as
599
+ * `profile-2026-09-12-2.md`, `-3`, … (the work-branch collision rule, the same
600
+ * spelling `prepareWorkBranch` uses). The date is the delivery's, not the
601
+ * run's: what a person wants to know opening the folder later is when the file
602
+ * arrived. D3: `destination` here is a FULL FILE PATH inside the destination
603
+ * DIRECTORY (`copyOut` builds one per output file it delivers) — this
604
+ * function itself is unchanged from M2b, it is just no longer handed the
605
+ * bare top-level destination directly.
606
+ *
607
+ * Nothing is ever overwritten, which is why the name is computed rather than
608
+ * taken: a plain `profile.md` would eventually collide with the person's own
609
+ * file or a previous run's, and the only honest answers to a collision are
610
+ * "refuse" or "a new name" — hamr picked a new name.
611
+ * @param {string} destination the absolute FILE path as declared
612
+ * @param {Date} now
613
+ * @param {number} n 1 = the plain dated name, 2+ = the same-day suffix
614
+ * @returns {string}
615
+ */
616
+ export function datedDestination(destination, now, n) {
617
+ const dir = dirname(destination);
618
+ const base = destination.slice(dir.length + 1);
619
+ const dot = base.lastIndexOf('.');
620
+ const stem = dot > 0 ? base.slice(0, dot) : base;
621
+ const ext = dot > 0 ? base.slice(dot) : '';
622
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
623
+ return join(dir, `${stem}-${day}${n > 1 ? `-${n}` : ''}${ext}`);
624
+ }
625
+
626
+ /**
627
+ * The first dated name that is free, or a refusal when every same-day slot up
628
+ * to `MAX_SAME_DAY` is taken. Used by `copyOut` at delivery time (per output
629
+ * file) so a same-day repeat delivery of the same name never disagrees with
630
+ * what "landable" means.
631
+ * @param {string} destination an absolute FILE path
632
+ * @param {Date} [now]
633
+ * @returns {{stop: null, path: string}|SourceRefusal}
634
+ */
635
+ export function pickDelivery(destination, now = new Date()) {
636
+ for (let n = 1; n <= MAX_SAME_DAY; n++) {
637
+ const candidate = datedDestination(destination, now, n);
638
+ if (!existsSync(candidate)) return { stop: null, path: candidate };
639
+ }
640
+ return refuse('destination-exists', `every same-day name for ${destination} is taken (up to -${MAX_SAME_DAY}) — bareloop never overwrites a person's file`);
641
+ }
642
+
643
+ /**
644
+ * Prove a destination DIRECTORY is landable — $0, mechanical, before any
645
+ * token spends (PRD item 33 ruling: "source and destination are proven at
646
+ * job start"). D3 rework (hamr's ruling, 2026-09-12): **destination is a
647
+ * directory, never a filename** — it may already exist, and need not be
648
+ * empty (a job may write into a folder that already holds other files, or
649
+ * even sit inside the source itself, the same-dir case). What this proves is
650
+ * narrower than the M2/M2b version: only that the directory is USABLE
651
+ * (exists as a directory and is writable, or can be created) — it can say
652
+ * nothing about individual delivered FILE names, because those are not known
653
+ * until the run actually produces them (the agent names its own output
654
+ * files); per-file same-day collisions are handled at delivery time, inside
655
+ * `copyOut`, via `pickDelivery`.
656
+ * @param {unknown} destination absolute directory path
657
+ * @param {{into: string}} o `into` — the run's own scratch root (prepared by
658
+ * `prepareSource`, or a run's own `workdir` when there is no source door);
659
+ * the destination may never sit inside it.
660
+ * @returns {Promise<{stop: null}|SourceRefusal>}
661
+ */
662
+ export async function proveDestination(destination, { into }) {
663
+ if (typeof destination !== 'string' || destination.length === 0) {
664
+ return refuse('destination-invalid', 'destination must be a non-empty absolute path');
665
+ }
666
+ if (!destination.startsWith(sep) && !/^[a-zA-Z]:[\\/]/.test(destination)) {
667
+ return refuse('destination-not-absolute', `${destination} is not absolute — the destination is a LOCAL directory the person moves elsewhere themselves, never a scope-relative path`);
668
+ }
669
+ const destAbs = resolve(destination);
670
+ const intoAbs = resolve(into);
671
+ if (destAbs === intoAbs || destAbs.startsWith(`${intoAbs}${sep}`)) {
672
+ return refuse('destination-contained', `${destination} sits inside ${into} — the drop-off point may never be inside the run's own scratch tree`);
673
+ }
674
+ let dstat = null;
675
+ try { dstat = await stat(destAbs); } catch { /* does not exist yet — proven creatable below */ }
676
+ if (dstat !== null) {
677
+ if (!dstat.isDirectory()) {
678
+ return refuse('destination-not-directory', `${destination} exists and is not a directory — the destination is a DIRECTORY a job writes one or more named files into, never a filename itself`);
679
+ }
680
+ try {
681
+ await access(destAbs, fsConstants.W_OK);
682
+ } catch {
683
+ return refuse('destination-not-writable', `${destination} exists but is not writable`);
684
+ }
685
+ return { stop: null };
686
+ }
687
+ const parent = dirname(destAbs);
688
+ let pstat;
689
+ try { pstat = await stat(parent); } catch {
690
+ return refuse('destination-parent-missing', `${parent} does not exist — create it first, or point the destination somewhere that does`);
691
+ }
692
+ if (!pstat.isDirectory()) {
693
+ return refuse('destination-parent-missing', `${parent} is not a directory`);
694
+ }
695
+ try {
696
+ await access(parent, fsConstants.W_OK);
697
+ } catch {
698
+ return refuse('destination-parent-unwritable', `${parent} exists but is not writable`);
699
+ }
700
+ return { stop: null };
701
+ }
702
+
703
+ /**
704
+ * Recursively list every REGULAR file under `dir`, relative to it, excluding
705
+ * `.gitkeep` (the seed placeholder that makes the empty `output/` directory
706
+ * exist in git — never a delivered file). A symlink is skipped, never
707
+ * followed — this door has no reason to ever deliver one, since nothing this
708
+ * run itself wrote under `output/` should be a link at all.
709
+ * @param {string} dir @param {string} [rel]
710
+ * @returns {Promise<string[]>}
711
+ */
712
+ async function listOutputFiles(dir, rel = '') {
713
+ let entries;
714
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
715
+ /** @type {string[]} */
716
+ let out = [];
717
+ for (const e of entries) {
718
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
719
+ if (relPath === '.gitkeep') continue;
720
+ if (e.isDirectory()) out = out.concat(await listOutputFiles(join(dir, e.name), relPath));
721
+ else if (e.isFile()) out.push(relPath);
722
+ }
723
+ return out;
724
+ }
725
+
726
+ /**
727
+ * Copy the run's own output to the proven destination DIRECTORY — once per
728
+ * file, never overwriting (`COPYFILE_EXCL`, so even a file that appears
729
+ * between `proveDestination` and here refuses rather than clobbers), on a
730
+ * minted green only. This function renders no verdict and changes none: a
731
+ * refusal here is reported beside the green it could not deliver, never
732
+ * folded back into it.
733
+ *
734
+ * D3 rework (hamr's ruling, 2026-09-12): a job may produce MORE THAN ONE
735
+ * file (his example: a flight search producing one sheet for "SFO→LAX
736
+ * red-eye" and another for "SFO under $700") — the agent names them
737
+ * meaningfully, and EVERY non-empty file under `tree/output/` (excluding
738
+ * `output/.gitkeep`) is delivered, each under its own dated name
739
+ * (`datedDestination`/`pickDelivery`, M2b fix 4, unchanged). An empty file is
740
+ * silently skipped (not a delivered result, same judgment M2b made for the
741
+ * single-file case) rather than refusing the whole batch over one empty
742
+ * file; the refusal is reserved for the case where NOTHING is left to
743
+ * deliver at all.
744
+ *
745
+ * `into`, when given, is the run's own SCRATCH ROOT (`prepareSource`'s
746
+ * `into`, of which `tree` is the `tree/` subdirectory) — review finding #3:
747
+ * a destination sitting inside `<into>` but outside `tree` (e.g. dropped
748
+ * straight in the scratch area, never inside the frozen tree) must still
749
+ * refuse `destination-contained`, which a containment check against `tree`
750
+ * alone cannot see. Omitted, it defaults to `tree` (the pre-fix behaviour),
751
+ * for direct callers that only ever had a tree, never an `into`.
752
+ * @param {{tree: string, into?: string, destination: string}} o
753
+ * @returns {Promise<{stop: null, files: {path: string, bytes: number, sha256: string}[]}|SourceRefusal>}
754
+ */
755
+ export async function copyOut({ tree, into, destination }) {
756
+ const prove = await proveDestination(destination, { into: into ?? tree });
757
+ if (prove.stop !== null) return prove;
758
+ const destAbs = resolve(destination);
759
+ await mkdir(destAbs, { recursive: true });
760
+ const outputDir = join(tree, 'output');
761
+ const candidates = await listOutputFiles(outputDir);
762
+ /** @type {{path: string, bytes: number, sha256: string}[]} */
763
+ const delivered = [];
764
+ for (const rel of candidates) {
765
+ const srcPath = join(outputDir, rel);
766
+ const buf = await readFile(srcPath);
767
+ if (buf.length === 0) continue; // an empty file is not a delivered result — skipped, never refused
768
+ const declaredFile = join(destAbs, rel);
769
+ await mkdir(dirname(declaredFile), { recursive: true });
770
+ const pick = pickDelivery(declaredFile);
771
+ if (pick.stop !== null) return pick;
772
+ try {
773
+ // `COPYFILE_EXCL` is the race backstop for a file that appears between
774
+ // `pickDelivery` choosing a free name and this line — a mutation that
775
+ // drops it survives every test in this file for exactly that reason,
776
+ // and is a known, accepted survivor, not a gap.
777
+ await copyFile(srcPath, pick.path, fsConstants.COPYFILE_EXCL);
778
+ } catch (e) {
779
+ if (/** @type {any} */ (e)?.code === 'EEXIST') return refuse('destination-exists', `${pick.path} already exists — bareloop never overwrites a person's file`);
780
+ return refuse('destination-write-failed', `copying to ${pick.path} failed: ${/** @type {Error} */ (e)?.message ?? String(e)}`);
781
+ }
782
+ delivered.push({ path: pick.path, bytes: buf.length, sha256: sha256Hex(buf) });
783
+ }
784
+ if (delivered.length === 0) {
785
+ return refuse('destination-output-missing', `no non-empty file exists under ${outputDir} — the run never produced anything this job was declared to write`);
786
+ }
787
+ return { stop: null, files: delivered };
788
+ }
789
+
790
+ /**
791
+ * Read a source-door manifest, if one is there. mid-build spec correction
792
+ * (2026-09-11, the main session, on hamr's question about export): a job
793
+ * spec is a SHAPE, signed once — source and destination are a PER-RUN value,
794
+ * the way `bareloop run <bundle> --repo <path>` varies the repo instance
795
+ * without touching the bundle's signature. Baking an absolute destination
796
+ * path into `job.js`/`jobSpecHash` would sign one instance where the spec is
797
+ * meant to describe a repeatable shape, so the run instance lives HERE
798
+ * instead — the manifest `prepareSource` already writes outside the tree —
799
+ * and nothing was added to the job schema.
800
+ *
801
+ * Absence is reported as absence: a patient the JOBS table points at
802
+ * directly, never having gone through `prepareSource`, has no manifest, and
803
+ * that IS "repo jobs untouched" — nothing here invents a destination for a
804
+ * job that never declared one. A manifest that EXISTS but cannot be read or
805
+ * parsed is a named stop, never a silent skip: the run has evidence
806
+ * something is there and wrong, and hiding that is worse than refusing on it.
807
+ * @param {string} into the manifest's own directory — `<into>/source.json`
808
+ * (a run-u-style caller passes `dirname(workdir)`, since `workdir` IS
809
+ * `<into>/tree`)
810
+ * @returns {Promise<{stop: null, present: false, manifest: null}|{stop: null, present: true, manifest: Record<string, any>}|SourceRefusal>}
811
+ */
812
+ export async function readSourceManifest(into) {
813
+ const manifestPath = join(resolve(into), 'source.json');
814
+ if (!existsSync(manifestPath)) return { stop: null, present: false, manifest: null };
815
+ let raw;
816
+ try { raw = await readFile(manifestPath, 'utf8'); } catch (e) {
817
+ return refuse('source-manifest-unreadable', `${manifestPath} exists but could not be read: ${/** @type {Error} */ (e)?.message ?? String(e)}`);
818
+ }
819
+ let manifest;
820
+ try { manifest = JSON.parse(raw); } catch (e) {
821
+ return refuse('source-manifest-invalid', `${manifestPath} is not valid JSON: ${/** @type {Error} */ (e)?.message ?? String(e)}`);
822
+ }
823
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
824
+ return refuse('source-manifest-invalid', `${manifestPath} must contain a JSON object`);
825
+ }
826
+ return { stop: null, present: true, manifest };
827
+ }
828
+
829
+ /**
830
+ * Reduce a `readSourceManifest` success result to the `{destination}` a
831
+ * runner acts on, or `null` when there is none to act on (no manifest, one
832
+ * that never declared a destination, or — D3 — a `kind: 'repo'` manifest,
833
+ * whose `destination` names the WRITE FENCE inside the copied repo, never a
834
+ * filesystem drop-off point this door proves or copies into: "repo jobs
835
+ * UNCHANGED", `writeScope` already does that job, `src/job.js:363`). Pure
836
+ * extraction of the one piece of `run-u.mjs`'s front-door reading that has
837
+ * any logic in it (the rest is spine emits and control flow) —
838
+ * unit-testable apart from the script, which is otherwise unreachable
839
+ * without a live provider key.
840
+ * @param {{present: boolean, manifest: Record<string, any>|null}} read
841
+ * @returns {{destination: string}|null}
842
+ */
843
+ export function frontDoorFromManifest(read) {
844
+ if (!read.present || !read.manifest) return null;
845
+ if (read.manifest.kind === 'repo') return null;
846
+ const { destination } = read.manifest;
847
+ if (typeof destination !== 'string' || !destination) return null;
848
+ return { destination };
849
+ }