bareloop 0.24.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/CHANGELOG.md +97 -1
- package/bareloop.context.md +177 -0
- package/package.json +1 -1
- package/src/index.js +9 -0
- package/src/kinds.js +7 -1
- package/src/source.js +849 -0
- package/src/validate.js +15 -0
- package/types/index.d.ts +1 -0
- package/types/kinds.d.ts +24 -0
- package/types/source.d.ts +206 -0
- package/types/validate.d.ts +7 -0
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
|
+
}
|