bareloop 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -0
- package/README.md +14 -0
- package/bareloop.context.md +331 -10
- package/bin/bareloop.mjs +24 -0
- package/package.json +7 -3
- package/src/authorjob.js +15 -1
- package/src/bundle.js +713 -0
- package/src/cli.js +472 -0
- package/src/close-integrity.js +325 -0
- package/src/closetimeout.js +165 -0
- package/src/index.js +31 -1
- package/src/job.js +48 -4
- package/src/plan.js +5 -1
- package/src/planrun.js +212 -7
- package/src/ralph.js +40 -7
- package/src/reuse.js +4 -2
- package/src/reviewdoor.js +26 -6
- package/src/run.js +24 -3
- package/src/validate.js +59 -0
- package/types/bundle.d.ts +199 -0
- package/types/cli.d.ts +19 -0
- package/types/close-integrity.d.ts +196 -0
- package/types/closetimeout.d.ts +126 -0
- package/types/index.d.ts +4 -1
- package/types/planrun.d.ts +17 -2
- package/types/ralph.d.ts +19 -5
- package/types/reuse.d.ts +16 -0
- package/types/reviewdoor.d.ts +3 -1
- package/types/run.d.ts +25 -2
- package/types/validate.d.ts +48 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Close integrity — PRD item 27 / M1(b), `docs/product/CLOSE-INTEGRITY-BUILD.md`.
|
|
2
|
+
//
|
|
3
|
+
// ONE detector for "does a close script bake in an absolute path", shared by
|
|
4
|
+
// `src/bundle.js` (export-time, mechanical) and the run-start precheck below
|
|
5
|
+
// (every job, not only bundles). Two spellings of the same rule is exactly
|
|
6
|
+
// how F129 shipped an export-only guard while every non-exported job stayed
|
|
7
|
+
// exposed — this module exists so that never happens again.
|
|
8
|
+
//
|
|
9
|
+
// `readCloseScripts` is the reader M2's sha256 fingerprint reuses: pure,
|
|
10
|
+
// never throws on a missing file (reports it absent so a caller can red
|
|
11
|
+
// distinctly from a tampered one). Scope (widened 2026-09-06, orchestrator
|
|
12
|
+
// audit): an INTERPRETER cmd (`node <path> …`, `sh <path> …`, …) OR a bare
|
|
13
|
+
// directly-executable ABSOLUTE path (a `.sh` wrapper with no interpreter
|
|
14
|
+
// prefix, e.g. `jobs/aurora-testgen-cold.json`'s close) — see
|
|
15
|
+
// `closeScriptCandidateToken` (src/validate.js) for the exact shape test.
|
|
16
|
+
// A relative bare executable (`true`, `pytest`) is still out of scope: it
|
|
17
|
+
// names no file at all, so there is nothing to read or sign.
|
|
18
|
+
//
|
|
19
|
+
// M2 (PRD item 27, `docs/product/CLOSE-INTEGRITY-BUILD.md`) adds the
|
|
20
|
+
// close-BYTES half beside the close-PATH half already here: `close[].sha256`
|
|
21
|
+
// is a hex sha256 of the same script bytes `readCloseScripts` already reads,
|
|
22
|
+
// so the fingerprint (`checkCloseByteSignature`/`checkStageByteSignature`)
|
|
23
|
+
// and the minting helper (`signCloseScripts`) both live in this one module —
|
|
24
|
+
// two spellings of "which bytes does this stage sign" is exactly the class
|
|
25
|
+
// of drift F129 shipped once already (see the module header above).
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
30
|
+
import { isObj, closeScriptCandidateToken } from './validate.js';
|
|
31
|
+
import { closeStagesOf } from './plan.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve a close stage's `cmd` to the addressable script's ABSOLUTE path on
|
|
35
|
+
* disk — the shape test itself (`closeScriptCandidateToken`, src/validate.js)
|
|
36
|
+
* is shared with `src/job.js`'s sha256 demand; this just resolves the
|
|
37
|
+
* resulting token against `cwd` when it is relative. Returns null for
|
|
38
|
+
* anything the shared shape test rejects (a relative bare executable, an
|
|
39
|
+
* interpreter with no argument, a non-string/empty cmd).
|
|
40
|
+
* @param {unknown} cmd
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @returns {string|null} absolute script path, or null if out of scope
|
|
43
|
+
*/
|
|
44
|
+
function closeScriptPathOf(cmd, cwd) {
|
|
45
|
+
const token = closeScriptCandidateToken(cmd);
|
|
46
|
+
if (token === null) return null;
|
|
47
|
+
return isAbsolute(token) ? token : resolve(cwd, token);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hex sha256 of a close script's TEXT, exactly the way `src/bundle.js`'s own
|
|
52
|
+
* `sha256(source)` hashes a close script's source before bundling (a JS
|
|
53
|
+
* source file is always valid UTF-8, so hashing the decoded string and
|
|
54
|
+
* hashing the raw file bytes agree) — ONE formula, so a signature minted here
|
|
55
|
+
* and a mismatch read at export time can never disagree about what "the
|
|
56
|
+
* bytes" means.
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {string} sha256 hex
|
|
59
|
+
*/
|
|
60
|
+
export function hashCloseScriptBytes(text) {
|
|
61
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A close script is entitled to name a real system path (`/usr/bin/env`,
|
|
66
|
+
* `/etc/hosts`, …) without that being the F8/F129 hazard — only a path a
|
|
67
|
+
* runner would actually relocate (a patient checkout, a scratch dir) is.
|
|
68
|
+
* @type {string[]}
|
|
69
|
+
*/
|
|
70
|
+
export const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
|
|
71
|
+
|
|
72
|
+
// Every quoted string literal (single/double/backtick). Backtick literals
|
|
73
|
+
// carrying `${…}` interpolation are filtered out below — they are not a
|
|
74
|
+
// fixed literal, so they cannot be "a path baked into the script" at all.
|
|
75
|
+
const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* F8/F129 — a close judges the cwd the runner gives it, never a path baked
|
|
79
|
+
* into the script itself. This scans every quoted string literal in a close
|
|
80
|
+
* script's source for an absolute POSIX path (`/…`) that EXISTS on this
|
|
81
|
+
* machine right now and is not under an allow-listed system prefix.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately monotone and simple, not clever, with named limits: a
|
|
84
|
+
* nonexistent absolute-looking string is treated as a NAME, not a path —
|
|
85
|
+
* `existsSync` is the only oracle this has for "load-bearing", so it is not
|
|
86
|
+
* flagged; a path built by string concatenation (never one whole literal)
|
|
87
|
+
* is invisible to this scan; and a literal that only exists inside a `//` or
|
|
88
|
+
* `/* *\/` comment is naturally excluded because a comment carries no quote
|
|
89
|
+
* characters of its own around the path text.
|
|
90
|
+
* @param {string} source close script text
|
|
91
|
+
* @returns {string[]} the offending literal path values, in source order
|
|
92
|
+
*/
|
|
93
|
+
export function absolutePathLiteralsOf(source) {
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
const hits = [];
|
|
96
|
+
for (const m of source.matchAll(STRING_LITERAL_RE)) {
|
|
97
|
+
const isBacktick = m[3] !== undefined;
|
|
98
|
+
const value = m[1] ?? m[2] ?? m[3];
|
|
99
|
+
if (value === undefined) continue;
|
|
100
|
+
if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
|
|
101
|
+
if (!value.startsWith('/')) continue;
|
|
102
|
+
if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
|
|
103
|
+
if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
|
|
104
|
+
hits.push(value);
|
|
105
|
+
}
|
|
106
|
+
return hits;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve every `close[].cmd` (or `closeDecl`'s equivalent, via
|
|
111
|
+
* `closeStagesOf`) that names an addressable script (`closeScriptCandidateToken`'s
|
|
112
|
+
* scope: an interpreter cmd or a bare absolute executable) to its absolute
|
|
113
|
+
* path and bytes off disk. The spec's OWN `cmd` path is the address and is never
|
|
114
|
+
* itself judged — only the named script's CONTENT is read here. A relative
|
|
115
|
+
* path resolves against `cwd` (the caller passes the run's own workdir or
|
|
116
|
+
* the spec file's directory, matching how each caller already resolves
|
|
117
|
+
* paths elsewhere). Never throws: a missing/unreadable script reports
|
|
118
|
+
* `bytes: null` so a caller can red it distinctly from a tampered one.
|
|
119
|
+
* `sha256` (M2, additive): the stage's OWN signed field, verbatim, or `null`
|
|
120
|
+
* when the stage carries none — the field this rung's checker compares
|
|
121
|
+
* against `hashCloseScriptBytes(bytes)`. M1's callers read `.stage`/`.path`/
|
|
122
|
+
* `.bytes` only, so this addition is behaviour-preserving for them.
|
|
123
|
+
* @param {any} spec resolved job spec (or anything `closeStagesOf` accepts)
|
|
124
|
+
* @param {string} cwd
|
|
125
|
+
* @returns {{stage: string, path: string, bytes: string|null, sha256: string|null}[]}
|
|
126
|
+
*/
|
|
127
|
+
export function readCloseScripts(spec, cwd) {
|
|
128
|
+
const stages = closeStagesOf(spec) ?? [];
|
|
129
|
+
/** @type {{stage: string, path: string, bytes: string|null, sha256: string|null}[]} */
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const stage of stages) {
|
|
132
|
+
if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
|
|
133
|
+
const scriptPath = closeScriptPathOf(stage.cmd, cwd);
|
|
134
|
+
if (!scriptPath) continue;
|
|
135
|
+
/** @type {string|null} */
|
|
136
|
+
let bytes = null;
|
|
137
|
+
try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
|
|
138
|
+
out.push({
|
|
139
|
+
stage: typeof stage.name === 'string' ? stage.name : scriptPath,
|
|
140
|
+
path: scriptPath,
|
|
141
|
+
bytes,
|
|
142
|
+
sha256: typeof stage.sha256 === 'string' ? stage.sha256 : null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The $0 run-start integrity check (PRD item 27(c)): red `close-absolute-path`
|
|
150
|
+
* for EVERY job whose close script bakes in an absolute path literal that
|
|
151
|
+
* exists on disk — the same rule `src/bundle.js`'s `exportBundle` already
|
|
152
|
+
* applies at export time, now applied before the close-first precheck and
|
|
153
|
+
* before any provider call, for every run, not only exported bundles. A
|
|
154
|
+
* script this reads couldn't read (missing/unreadable) is not reported here
|
|
155
|
+
* — that is `broken-close`'s job once the close actually tries to run it.
|
|
156
|
+
* @param {any} spec resolved job spec
|
|
157
|
+
* @param {string} cwd
|
|
158
|
+
* @returns {{ ok: true } | { ok: false, reds: {stage: string, path: string, literal: string}[] }}
|
|
159
|
+
*/
|
|
160
|
+
export function checkCloseAbsolutePaths(spec, cwd) {
|
|
161
|
+
const scripts = readCloseScripts(spec, cwd);
|
|
162
|
+
/** @type {{stage: string, path: string, literal: string}[]} */
|
|
163
|
+
const reds = [];
|
|
164
|
+
for (const s of scripts) {
|
|
165
|
+
if (s.bytes == null) continue;
|
|
166
|
+
for (const literal of absolutePathLiteralsOf(s.bytes)) {
|
|
167
|
+
reds.push({ stage: s.stage, path: s.path, literal });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return reds.length > 0 ? { ok: false, reds } : { ok: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @typedef {{stage: string, path: string, expected: string, actual: string|null}} ByteRed */
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The core comparison, shared by the two checkers below: a `readCloseScripts`
|
|
177
|
+
* row signs nothing (`sha256: null`, e.g. a `.sh`-cmd stage or a declared
|
|
178
|
+
* kind) reads as `ok` — M2's validator is what demands the field on the
|
|
179
|
+
* stages that must carry it; this runtime check only ever compares a
|
|
180
|
+
* signature that exists.
|
|
181
|
+
* @param {{stage: string, path: string, bytes: string|null, sha256: string|null}[]} scripts
|
|
182
|
+
* @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
|
|
183
|
+
*/
|
|
184
|
+
function compareSignatures(scripts) {
|
|
185
|
+
/** @type {ByteRed[]} */
|
|
186
|
+
const reds = [];
|
|
187
|
+
for (const s of scripts) {
|
|
188
|
+
if (s.sha256 == null) continue;
|
|
189
|
+
const actual = s.bytes == null ? null : hashCloseScriptBytes(s.bytes);
|
|
190
|
+
if (actual !== s.sha256) reds.push({ stage: s.stage, path: s.path, expected: s.sha256, actual });
|
|
191
|
+
}
|
|
192
|
+
return reds.length > 0 ? { ok: false, reds } : { ok: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* M2's run-start integrity check (PRD item 27, `close-tampered`): every
|
|
197
|
+
* signed `close[].sha256` must match the script's bytes off disk, RIGHT NOW —
|
|
198
|
+
* checked before the close-first precheck and before any provider call,
|
|
199
|
+
* exactly where `checkCloseAbsolutePaths` already runs (the two are always
|
|
200
|
+
* called together at that seam). A script this cannot read reads `actual:
|
|
201
|
+
* null` — distinct from a byte mismatch, but still a red: an unreadable
|
|
202
|
+
* signed script is exactly as untrustworthy as a tampered one, and silently
|
|
203
|
+
* treating "gone" as "fine" would be the M1 F129 hazard in a new coat.
|
|
204
|
+
* @param {any} spec resolved job spec
|
|
205
|
+
* @param {string} cwd
|
|
206
|
+
* @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
|
|
207
|
+
*/
|
|
208
|
+
export function checkCloseByteSignature(spec, cwd) {
|
|
209
|
+
return compareSignatures(readCloseScripts(spec, cwd));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** the exact env var name a close script reads for its own books directory
|
|
213
|
+
* (Part B, PRD item 27/M3) — named once so the detector below and the runner
|
|
214
|
+
* that sets it (`src/ralph.js`'s `runClose`) can never drift into two
|
|
215
|
+
* spellings of the same contract. */
|
|
216
|
+
export const CLOSE_DIR_ENV_VAR = 'BARELOOP_CLOSE_DIR';
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* PRD item 27/M3, Part B — "the library `runJob` takes `closeDir` and refuses
|
|
220
|
+
* at $0 if a spec's close needs it and none is given." A close script's
|
|
221
|
+
* NEED is read the same way its bytes already are (`readCloseScripts`): a
|
|
222
|
+
* script whose source mentions `BARELOOP_CLOSE_DIR` reads that variable at
|
|
223
|
+
* run time, so a run with no `closeDir` handed to it would run that script
|
|
224
|
+
* against `undefined` — silently, since `env.BARELOOP_CLOSE_DIR` would just
|
|
225
|
+
* be absent from the child's environment rather than throwing. Refusing here,
|
|
226
|
+
* at the same $0 run-start seam as the absolute-path/byte-signature checks,
|
|
227
|
+
* turns that silent gap into a named red before any tokens spend, rather than
|
|
228
|
+
* leaving it to whatever the script's own author remembered to check for.
|
|
229
|
+
*
|
|
230
|
+
* A script that never mentions the variable carries no demand — most
|
|
231
|
+
* predicate-only fixture closes (`true`, `pytest`) have no books of their own
|
|
232
|
+
* to keep, and requiring a `closeDir` for them would be a needless refusal.
|
|
233
|
+
* @param {any} spec resolved job spec
|
|
234
|
+
* @param {string} cwd
|
|
235
|
+
* @param {string|null|undefined} closeDir the value the runner is about to hand
|
|
236
|
+
* the close as `BARELOOP_CLOSE_DIR` (or none)
|
|
237
|
+
* @returns {{ ok: true } | { ok: false, reds: {stage: string, path: string}[] }}
|
|
238
|
+
*/
|
|
239
|
+
export function checkCloseDirRequired(spec, cwd, closeDir) {
|
|
240
|
+
if (typeof closeDir === 'string' && closeDir.length > 0) return { ok: true };
|
|
241
|
+
const scripts = readCloseScripts(spec, cwd);
|
|
242
|
+
/** @type {{stage: string, path: string}[]} */
|
|
243
|
+
const reds = [];
|
|
244
|
+
for (const s of scripts) {
|
|
245
|
+
if (s.bytes != null && s.bytes.includes(CLOSE_DIR_ENV_VAR)) reds.push({ stage: s.stage, path: s.path });
|
|
246
|
+
}
|
|
247
|
+
return reds.length > 0 ? { ok: false, reds } : { ok: true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The re-verify that runs before EVERY close run, precheck included (PRD
|
|
252
|
+
* item 27, M2 §2) — scoped to the STAGES about to actually run rather than
|
|
253
|
+
* the whole spec, because that is the true blast radius of "was this close
|
|
254
|
+
* script's content tampered with since it was signed" for this particular
|
|
255
|
+
* invocation. Same comparison, same red shape, as `checkCloseByteSignature`;
|
|
256
|
+
* kept as a separate export because its caller (`runCloseStages` in
|
|
257
|
+
* `src/planrun.js`) already holds a stage array, not a spec.
|
|
258
|
+
* @param {any[]} stages a close stage chain (the shape `runStages` takes)
|
|
259
|
+
* @param {string} cwd
|
|
260
|
+
* @returns {{ ok: true } | { ok: false, reds: ByteRed[] }}
|
|
261
|
+
*/
|
|
262
|
+
export function checkStageByteSignature(stages, cwd) {
|
|
263
|
+
const scripts = [];
|
|
264
|
+
for (const stage of Array.isArray(stages) ? stages : []) {
|
|
265
|
+
if (!isObj(stage) || typeof stage.cmd !== 'string') continue;
|
|
266
|
+
const scriptPath = closeScriptPathOf(stage.cmd, cwd);
|
|
267
|
+
if (!scriptPath) continue;
|
|
268
|
+
/** @type {string|null} */
|
|
269
|
+
let bytes = null;
|
|
270
|
+
try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
|
|
271
|
+
scripts.push({
|
|
272
|
+
stage: typeof stage.name === 'string' ? stage.name : scriptPath,
|
|
273
|
+
path: scriptPath,
|
|
274
|
+
bytes,
|
|
275
|
+
sha256: typeof stage.sha256 === 'string' ? stage.sha256 : null,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return compareSignatures(scripts);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* M2's minting helper (PRD item 27 §3) — fills `close[].sha256` from disk for
|
|
283
|
+
* every addressable-script stage in `spec.close` (`closeScriptCandidateToken`'s
|
|
284
|
+
* scope: an interpreter cmd or a bare absolute executable, e.g. a `.sh`
|
|
285
|
+
* wrapper). Pure: never writes anything itself (the CLI wrapper,
|
|
286
|
+
* `scripts/sign-close.mjs`, gates on `--write`) and never throws on a
|
|
287
|
+
* missing/unreadable script — it names the miss in `missing` and leaves
|
|
288
|
+
* that stage's `sha256` untouched, so a caller can refuse to sign (and
|
|
289
|
+
* refuse to report a hash it never computed) a spec it could not fully
|
|
290
|
+
* hash. A stage outside this rung's scope (a relative bare executable, a
|
|
291
|
+
* declared-kind stage) passes through unchanged and is never listed as
|
|
292
|
+
* missing — it was never addressable to begin with (same scope
|
|
293
|
+
* `readCloseScripts`/`checkCloseAbsolutePaths` already draw).
|
|
294
|
+
*
|
|
295
|
+
* Only ever operates on `spec.close` (the command-close array) — a
|
|
296
|
+
* `closeDecl` spec has no script path to sign and `signCloseScripts` reports
|
|
297
|
+
* `ok:false` for one rather than silently doing nothing to it.
|
|
298
|
+
* @param {any} spec resolved job spec (parsed `jobs/<x>.json`)
|
|
299
|
+
* @param {string} cwd base for resolving a RELATIVE script path (jobs in this
|
|
300
|
+
* repo only ever use absolute `cmd` paths; relative resolution is here for
|
|
301
|
+
* parity with `readCloseScripts` and any future relative-path spec)
|
|
302
|
+
* @returns {{ ok: boolean, spec: any, changes: {stage: string, path: string, old: string|null, new: string}[], missing: {stage: string, path: string}[] }}
|
|
303
|
+
*/
|
|
304
|
+
export function signCloseScripts(spec, cwd) {
|
|
305
|
+
if (!isObj(spec) || !Array.isArray(spec.close)) {
|
|
306
|
+
return { ok: false, spec, changes: [], missing: [] };
|
|
307
|
+
}
|
|
308
|
+
/** @type {{stage: string, path: string, old: string|null, new: string}[]} */
|
|
309
|
+
const changes = [];
|
|
310
|
+
/** @type {{stage: string, path: string}[]} */
|
|
311
|
+
const missing = [];
|
|
312
|
+
const newClose = spec.close.map((/** @type {any} */ stage) => {
|
|
313
|
+
if (!isObj(stage)) return stage;
|
|
314
|
+
const scriptPath = closeScriptPathOf(stage.cmd, cwd);
|
|
315
|
+
if (!scriptPath) return stage; // out of this rung's scope — never touched, never reported missing
|
|
316
|
+
/** @type {string|null} */
|
|
317
|
+
let bytes = null;
|
|
318
|
+
try { bytes = readFileSync(scriptPath, 'utf8'); } catch { bytes = null; }
|
|
319
|
+
if (bytes == null) { missing.push({ stage: stage.name, path: scriptPath }); return stage; }
|
|
320
|
+
const hash = hashCloseScriptBytes(bytes);
|
|
321
|
+
changes.push({ stage: stage.name, path: scriptPath, old: typeof stage.sha256 === 'string' ? stage.sha256 : null, new: hash });
|
|
322
|
+
return { ...stage, sha256: hash };
|
|
323
|
+
});
|
|
324
|
+
return { ok: missing.length === 0, spec: { ...spec, close: newClose }, changes, missing };
|
|
325
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Close timeout — PRD item 27/M3, `docs/product/CLOSE-INTEGRITY-BUILD.md`.
|
|
2
|
+
//
|
|
3
|
+
// hamr's ruling (2026-09-06, verbatim): "both … autoset and can be override,
|
|
4
|
+
// same like api pricing" — the guesstimate-plus-loud-sign-plus-customer-
|
|
5
|
+
// override shape, never a hidden knob. Today `runClose`'s own default
|
|
6
|
+
// (`src/ralph.js:221`, `timeoutMs = 120_000`) is a defaulted cap with no
|
|
7
|
+
// operator-set floor — the same shape the wall's no-default rule already
|
|
8
|
+
// forbids for `maxWallMs`. This module is the fix: measure the close's own
|
|
9
|
+
// stages once at $0, before any provider call, and derive a per-stage ceiling
|
|
10
|
+
// from what was actually measured, unless the signed spec names one itself.
|
|
11
|
+
//
|
|
12
|
+
// CONSTANTS BELOW ARE ARBITER CONSTANTS — hamr set them 2026-09-07, verbatim:
|
|
13
|
+
// "floor = 120,000 ms; K = 5 ('2 and 5 are fine')." Never agent- or
|
|
14
|
+
// adopter-settable; a build changing them is arbiter territory, not a tuning
|
|
15
|
+
// knob a drafted spec or a runner script can reach for.
|
|
16
|
+
|
|
17
|
+
import { runClose } from './ralph.js';
|
|
18
|
+
|
|
19
|
+
/** hamr, 2026-09-07: the floor, verbatim. Also the value `runClose`'s own
|
|
20
|
+
* un-autoset default has always used — kept as its own named constant here
|
|
21
|
+
* (not re-exported from ralph.js) because the two are different CLAIMS: one is
|
|
22
|
+
* "what runClose does if nobody says otherwise", the other is "the arbiter's
|
|
23
|
+
* chosen floor for autoset" — they happen to be the same number today, and a
|
|
24
|
+
* future change to either must not silently move the other. */
|
|
25
|
+
export const CLOSE_TIMEOUT_FLOOR_MS = 120_000;
|
|
26
|
+
/** hamr, 2026-09-07, verbatim: "K = 5 ('2 and 5 are fine')." */
|
|
27
|
+
export const CLOSE_TIMEOUT_K = 5;
|
|
28
|
+
/** `runClose`'s own shipped default when nobody passes a timeout at all
|
|
29
|
+
* (`src/ralph.js:221`) — the ceiling the $0 timing PASS ITSELF runs each
|
|
30
|
+
* stage under is a multiple of this, never of the floor above (the two are
|
|
31
|
+
* the same number today; named separately on purpose, see the constant
|
|
32
|
+
* above). */
|
|
33
|
+
export const LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS = 120_000;
|
|
34
|
+
/**
|
|
35
|
+
* The generous PROVISIONAL ceiling the timing preflight runs every stage
|
|
36
|
+
* under, so "a genuinely hung stage still ends" (build spec, M3 §2) rather
|
|
37
|
+
* than blocking the timing pass forever. Deliberately the library default × K
|
|
38
|
+
* (600s) — NOT the autoset/override ceiling this module computes, which is
|
|
39
|
+
* only known after the pass completes.
|
|
40
|
+
*/
|
|
41
|
+
export const TIMING_PREFLIGHT_CEILING_MS = LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS * CLOSE_TIMEOUT_K;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The $0 timing preflight (PRD item 27, M3 §2): run every close STAGE once,
|
|
45
|
+
* ignoring verdicts — first-red-wins stays for the real verdict precheck
|
|
46
|
+
* (`src/planrun.js`'s `check-preflight`); this is a separate, sequential pass
|
|
47
|
+
* (F131/F68: never two closes in flight against one tree) whose only purpose
|
|
48
|
+
* is TIMING. A declared-close stage (no `.cmd`, a kind executor) has nothing
|
|
49
|
+
* a command runner can spawn and is skipped — it contributes no reading, not
|
|
50
|
+
* a zero one (a silent zero would understate the real close's later cost).
|
|
51
|
+
*
|
|
52
|
+
* @param {any[]} stages a close stage chain (`runStages`'s own shape)
|
|
53
|
+
* @param {{cwd?: string, redact?: (s: string) => string, ceilingMs?: number}} [opts]
|
|
54
|
+
* `ceilingMs` (test seam only — never wired to any spec/runner surface):
|
|
55
|
+
* overrides `TIMING_PREFLIGHT_CEILING_MS` so a hang-detection test does not
|
|
56
|
+
* have to wait 10 real minutes. Production callers never pass it.
|
|
57
|
+
* @returns {Promise<{
|
|
58
|
+
* perStage: {name: string, ms: number, exitCode: number|null, timedOut: boolean}[],
|
|
59
|
+
* slowestMs: number, slowestName: string|null, anyTimedOut: boolean,
|
|
60
|
+
* }>}
|
|
61
|
+
*/
|
|
62
|
+
export async function timeCloseStages(stages, { cwd, redact = (s) => s, ceilingMs = TIMING_PREFLIGHT_CEILING_MS } = {}) {
|
|
63
|
+
/** @type {{name: string, ms: number, exitCode: number|null, timedOut: boolean}[]} */
|
|
64
|
+
const perStage = [];
|
|
65
|
+
for (const st of Array.isArray(stages) ? stages : []) {
|
|
66
|
+
if (!st || typeof st !== 'object' || typeof st.cmd !== 'string') continue; // declared/kind stage — nothing to spawn
|
|
67
|
+
const start = Date.now();
|
|
68
|
+
// Sequential by construction (F68), same discipline as `runStages`: a
|
|
69
|
+
// stage's own timing pass is never in flight beside another close.
|
|
70
|
+
// eslint-disable-next-line no-await-in-loop
|
|
71
|
+
const r = await runClose(st.cmd.trim().split(/\s+/), redact, {
|
|
72
|
+
timeoutMs: ceilingMs, cwd, expect: st.expect, judged: st.judged, gapKeep: st.gapKeep,
|
|
73
|
+
});
|
|
74
|
+
const ms = Date.now() - start;
|
|
75
|
+
// `runClose` only reports `exitCode` on a NON-satisfied verdict (a
|
|
76
|
+
// satisfied close's exit code is implied by `expect`, never re-stated) —
|
|
77
|
+
// this timing pass records it either way, since "which exit code did
|
|
78
|
+
// this stage return" is a cheap, honest thing to keep for `close-timing`.
|
|
79
|
+
const exitCode = typeof r.exitCode === 'number' ? r.exitCode
|
|
80
|
+
: (r.verdict === 'satisfied' && typeof st.expect === 'number' ? st.expect : null);
|
|
81
|
+
perStage.push({
|
|
82
|
+
name: typeof st.name === 'string' ? st.name : st.cmd,
|
|
83
|
+
ms,
|
|
84
|
+
exitCode,
|
|
85
|
+
timedOut: r.verdict === 'timed-out',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
let slowest = null;
|
|
89
|
+
for (const s of perStage) if (slowest === null || s.ms > slowest.ms) slowest = s;
|
|
90
|
+
return {
|
|
91
|
+
perStage,
|
|
92
|
+
slowestMs: slowest ? slowest.ms : 0,
|
|
93
|
+
slowestName: slowest ? slowest.name : null,
|
|
94
|
+
anyTimedOut: perStage.some((s) => s.timedOut),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The ceiling formula (PRD item 27, M3): `max(FLOOR, K × slowest measured
|
|
100
|
+
* stage)`, unless an explicit override is given — the override is the
|
|
101
|
+
* OPERATOR'S number (the rates-passthrough shape, F113: guesstimate + loud
|
|
102
|
+
* sign + customer override), so it may legally sit ABOVE or BELOW the
|
|
103
|
+
* autoset estimate; this is not tighten-only.
|
|
104
|
+
* @param {{slowestMs: number, overrideMs?: number|null}} args
|
|
105
|
+
* @returns {{ ceilingMs: number, source: 'estimated'|'override' }}
|
|
106
|
+
*/
|
|
107
|
+
export function computeCloseTimeoutCeiling({ slowestMs, overrideMs = null }) {
|
|
108
|
+
if (typeof overrideMs === 'number' && Number.isFinite(overrideMs)) {
|
|
109
|
+
return { ceilingMs: overrideMs, source: 'override' };
|
|
110
|
+
}
|
|
111
|
+
return { ceilingMs: Math.max(CLOSE_TIMEOUT_FLOOR_MS, CLOSE_TIMEOUT_K * slowestMs), source: 'estimated' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ONE resolver for "what per-stage close timeout does this job get", shared
|
|
116
|
+
* by `src/planrun.js` (the in-run path) and `scripts/run-u.mjs` (which must
|
|
117
|
+
* know the answer BEFORE it spawns the outside watchdog — F67 — so the
|
|
118
|
+
* watchdog's own stale/grace windows are sized off the real ceiling rather
|
|
119
|
+
* than a hardcoded guess). Two callers computing this independently is
|
|
120
|
+
* exactly the class of drift this whole rung exists to close (F129's own
|
|
121
|
+
* lesson, one level up): this function is the only place the precedence is
|
|
122
|
+
* spelled out.
|
|
123
|
+
*
|
|
124
|
+
* Precedence: a signed spec override (`job.closeTimeoutMs`) wins outright,
|
|
125
|
+
* with NO timing pass run (the pass would waste real machine time and, on a
|
|
126
|
+
* deliberately slow/hung stage, block for up to `TIMING_PREFLIGHT_CEILING_MS`
|
|
127
|
+
* for no purpose). Absent that, every stage is timed once (F131's autoset
|
|
128
|
+
* design) and the ceiling is `max(FLOOR, K × slowest)`.
|
|
129
|
+
* @param {{job: any, stages: any[], cwd: string, redact?: (s: string) => string, ceilingMs?: number}} args
|
|
130
|
+
* `ceilingMs` — see `timeCloseStages`; test seam only.
|
|
131
|
+
* @returns {Promise<{
|
|
132
|
+
* closeTimeoutMs: number|null, source: 'estimated'|'override',
|
|
133
|
+
* timing: Awaited<ReturnType<typeof timeCloseStages>>|null, timedOut: boolean,
|
|
134
|
+
* }>} `closeTimeoutMs: null, timedOut: true` means a stage never finished the
|
|
135
|
+
* timing pass — the caller reds `close-timing-red` rather than trusting any
|
|
136
|
+
* number here.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveCloseTimeoutMs({ job, stages, cwd, redact = (s) => s, ceilingMs }) {
|
|
139
|
+
const signedOverride = typeof job?.closeTimeoutMs === 'number' ? job.closeTimeoutMs : null;
|
|
140
|
+
if (signedOverride !== null) {
|
|
141
|
+
return { closeTimeoutMs: signedOverride, source: 'override', timing: null, timedOut: false };
|
|
142
|
+
}
|
|
143
|
+
const timing = await timeCloseStages(stages, { cwd, redact, ...(ceilingMs !== undefined ? { ceilingMs } : {}) });
|
|
144
|
+
if (timing.anyTimedOut) return { closeTimeoutMs: null, source: 'estimated', timing, timedOut: true };
|
|
145
|
+
const computed = computeCloseTimeoutCeiling({ slowestMs: timing.slowestMs });
|
|
146
|
+
return { closeTimeoutMs: computed.ceilingMs, source: computed.source, timing, timedOut: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The printed banner (build spec M3 §2's exact wording) — ONE spelling, so
|
|
151
|
+
* the run-u tail and the bundle CLI can never drift into two announcements of
|
|
152
|
+
* the same number. `source` accepts 'explicit' too: a caller-passed
|
|
153
|
+
* `closeTimeoutMs` runtime option (the pre-M3 shell/test knob, kept for
|
|
154
|
+
* backward compatibility and test control — never the signed spec field) is
|
|
155
|
+
* a real override but not a SIGNED one, so it earns its own honest word
|
|
156
|
+
* rather than being folded into 'signed override'.
|
|
157
|
+
* @param {{ceilingMs: number, source: 'estimated'|'override'|'explicit', slowestMs?: number, slowestName?: string|null}} args
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function closeTimeoutBanner({ ceilingMs, source, slowestMs, slowestName }) {
|
|
161
|
+
const seconds = Math.round(ceilingMs / 1000);
|
|
162
|
+
if (source === 'override') return `close timeout: ${seconds}s per stage (signed override)`;
|
|
163
|
+
if (source === 'explicit') return `close timeout: ${seconds}s per stage (explicit runner override)`;
|
|
164
|
+
return `close timeout: ${seconds}s per stage (estimated from seed timing: slowest ${slowestName ?? '<none>'} ${slowestMs ?? 0}ms × ${CLOSE_TIMEOUT_K}, floor ${Math.round(CLOSE_TIMEOUT_FLOOR_MS / 1000)}s)`;
|
|
165
|
+
}
|
package/src/index.js
CHANGED
|
@@ -81,7 +81,7 @@ export { renderListing, selectionPrompt } from './selection.js';
|
|
|
81
81
|
// at a review door, and a door with no row to act on can only describe a held learning
|
|
82
82
|
// credit it has no way to release. STORAGE ONLY — nothing here selects, promotes or reuses
|
|
83
83
|
// a bridge; those stay parked on `layer-3-reuse`.
|
|
84
|
-
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow } from './reuse.js';
|
|
84
|
+
export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS, checkpointAgeGate, applyDoorDecision, writeRunGreenRow, shapeForkName } from './reuse.js';
|
|
85
85
|
// BA-21 pricing provenance rides out with the ledger because it is the same job: reading
|
|
86
86
|
// a run's own spend record honestly. `rateProvenance`/`spendProvenance` answer "how much of
|
|
87
87
|
// this was priced by a rate nobody vouched for" — REPORTING ONLY, no halt, no refusal.
|
|
@@ -252,3 +252,33 @@ export {
|
|
|
252
252
|
// whether a changed file is one. The ONE place this list lives; exported so
|
|
253
253
|
// bareloop.context.md can point an adopter here instead of re-deriving it.
|
|
254
254
|
export { PROMPT_REGISTERS, isPromptFile } from './promptregisters.js';
|
|
255
|
+
// Export M1 (`docs/product/EXPORT-BUILD.md`) — the bundle a `bareloop run`
|
|
256
|
+
// (M2) drives from a clean consumer directory: a signed spec (close paths
|
|
257
|
+
// rewritten to `$BARELOOP_BUNDLE/close/<script>`), the close scripts it
|
|
258
|
+
// needs, and the job's whole registry history. `bundle.js` reads this
|
|
259
|
+
// module's own export list at call time (never a hand-kept copy) to judge
|
|
260
|
+
// whether a close script's imports are legal — a cycle by design, safe
|
|
261
|
+
// because the binding is read only inside function bodies.
|
|
262
|
+
export {
|
|
263
|
+
exportBundle, bundleHash, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
|
|
264
|
+
} from './bundle.js';
|
|
265
|
+
// Close integrity (PRD item 27 / M1, `docs/product/CLOSE-INTEGRITY-BUILD.md`)
|
|
266
|
+
// — the ONE `close-absolute-path` detector shared by `exportBundle` (above)
|
|
267
|
+
// and `runPlan`'s run-start precheck. `readCloseScripts` is the pure reader
|
|
268
|
+
// M2's sha256 fingerprint will reuse.
|
|
269
|
+
export { readCloseScripts, checkCloseAbsolutePaths, absolutePathLiteralsOf } from './close-integrity.js';
|
|
270
|
+
// Close integrity M2 (PRD item 27, `docs/product/CLOSE-INTEGRITY-BUILD.md`) —
|
|
271
|
+
// the close-BYTES half. `hashCloseScriptBytes`/`checkCloseByteSignature` are
|
|
272
|
+
// the fingerprint the run-start precheck and `exportBundle` both compare
|
|
273
|
+
// against; `checkStageByteSignature` is the narrower re-verify `runCloseStages`
|
|
274
|
+
// (src/planrun.js) runs before every close run; `signCloseScripts` is the
|
|
275
|
+
// pure minting helper `scripts/sign-close.mjs` wraps (never writes itself).
|
|
276
|
+
export {
|
|
277
|
+
hashCloseScriptBytes, checkCloseByteSignature, checkStageByteSignature, signCloseScripts,
|
|
278
|
+
} from './close-integrity.js';
|
|
279
|
+
// Export M2 (`docs/product/EXPORT-BUILD.md`) — the CLI's one entry point,
|
|
280
|
+
// exported as `cliMain` (never `main`, a name generic enough to collide with
|
|
281
|
+
// an adopter's own). `bin/bareloop.mjs` is the only other caller; this export
|
|
282
|
+
// exists so an adopter driving `bareloop run` from their own script can do so
|
|
283
|
+
// without spawning `bin/bareloop.mjs` as a child process.
|
|
284
|
+
export { main as cliMain } from './cli.js';
|
package/src/job.js
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// judgment call. Minting policy is product doctrine, not job-authorable.
|
|
15
15
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
|
-
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
|
|
17
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier, closeScriptCandidateToken } from './validate.js';
|
|
18
18
|
import { validateCloseDecl, DECLARED_CLOSE_CLASSES } from './declaredclose.js';
|
|
19
|
+
import { CLOSE_TIMEOUT_FLOOR_MS } from './closetimeout.js';
|
|
19
20
|
|
|
20
21
|
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
21
22
|
// they are read at call time, so a mutable export would let adopter code
|
|
@@ -140,7 +141,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
|
|
|
140
141
|
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
141
142
|
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
142
143
|
// the operator gets told what happened, not just that something is wrong.
|
|
143
|
-
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
|
|
144
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
|
|
144
145
|
/** the four-field plan shape's core (decision 5) — presence of any of these
|
|
145
146
|
* declares the shape; `tools` (the ceiling) rides the shape but alone does not
|
|
146
147
|
* declare it, so a legacy spec carrying it gets a pointed red, not a conflict */
|
|
@@ -148,7 +149,7 @@ const PLAN_CORE_FIELDS = ['goal', 'verdictType', 'close', 'closeDecl', 'checks']
|
|
|
148
149
|
/** exact field set per close type — anything else is an unknown-field red
|
|
149
150
|
* (freeform code, script bodies, and minting claims all land there) */
|
|
150
151
|
const CLOSE_FIELDS = {
|
|
151
|
-
predicate: ['type', 'cmd', 'expect', 'judged', 'gapKeep'],
|
|
152
|
+
predicate: ['type', 'cmd', 'expect', 'judged', 'gapKeep', 'sha256'],
|
|
152
153
|
gold: ['type', 'expected', 'compare'],
|
|
153
154
|
rubric: ['type', 'criteria'],
|
|
154
155
|
hitl: ['type', 'prompt'],
|
|
@@ -240,6 +241,21 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
240
241
|
`integer milliseconds >= ${MIN_WALL_MS} (one close timeout). Enforcement is a between-round deadline, measured as maxWallMs + closeStages x closeTimeoutMs (design addendum 1: loop.stop() cannot cut an in-flight call; W5: every close stage runs under the full timeout), so a budget under one close cannot fund its own close and the advertised number would be more wrong than right`);
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
// CLOSE TIMEOUT (PRD item 27/M3, hamr 2026-09-06: "both … autoset and can be
|
|
245
|
+
// override, same like api pricing"). OPTIONAL and WITH NO CEILING: absent
|
|
246
|
+
// means the runner autosets it from a $0 seed timing pass
|
|
247
|
+
// (`src/closetimeout.js`); present is the OPERATOR's own number and — like a
|
|
248
|
+
// rates-passthrough override (F113) — it may legally sit ABOVE the autoset
|
|
249
|
+
// estimate, so this is not tighten-only. The one bound that IS enforced is
|
|
250
|
+
// the floor every close timeout has always had (`CLOSE_TIMEOUT_FLOOR_MS`,
|
|
251
|
+
// hamr's arbiter constant, 2026-09-07): a value below it could never fund
|
|
252
|
+
// even a fast close, exactly the "advertised number more wrong than right"
|
|
253
|
+
// failure `maxWallMs` already guards above.
|
|
254
|
+
if (spec.closeTimeoutMs !== undefined
|
|
255
|
+
&& !(typeof spec.closeTimeoutMs === 'number' && Number.isInteger(spec.closeTimeoutMs) && spec.closeTimeoutMs >= CLOSE_TIMEOUT_FLOOR_MS)) {
|
|
256
|
+
red('bounds', 'closeTimeoutMs', `integer milliseconds >= ${CLOSE_TIMEOUT_FLOOR_MS} (the arbiter's floor) — a signed override may sit above the autoset estimate, never below the floor every close timeout has always had`);
|
|
257
|
+
}
|
|
258
|
+
|
|
243
259
|
// MODEL — the worker's provider model id (build-list #3, hamr's GO
|
|
244
260
|
// 2026-08-30). OPTIONAL, exactly like budgetUsd/maxWallMs before it: absent
|
|
245
261
|
// means today's behaviour (the runner's own default), present it is part
|
|
@@ -356,6 +372,34 @@ function predicateBody(o, at, red) {
|
|
|
356
372
|
// refusal beats silent misparse (N2 design default)
|
|
357
373
|
else if (/["']/.test(o.cmd)) red('invalid-value', `${at}.cmd`, 'quote characters are inexpressible: cmd runs as whitespace-split argv, no shell');
|
|
358
374
|
else if (o.cmd !== o.cmd.trim()) red('invalid-value', `${at}.cmd`, 'leading/trailing whitespace — argv splits on whitespace and an empty argv[0] cannot spawn; honest refusal beats a silent misparse');
|
|
375
|
+
// Close-bytes signature (PRD item 27/M2, F129's general fix's own general
|
|
376
|
+
// fix — N4): the signature must cover the close SCRIPT'S CONTENT, not only
|
|
377
|
+
// its path (`cmd`). Demanded exactly where a byte-exact fingerprint can be
|
|
378
|
+
// minted against something: `cmd` names an addressable script by the
|
|
379
|
+
// SAME shape test `src/close-integrity.js`'s detectors use
|
|
380
|
+
// (`closeScriptCandidateToken`, src/validate.js — `node <path> …`, OR a
|
|
381
|
+
// bare directly-executable absolute path such as a `.sh` wrapper; widened
|
|
382
|
+
// 2026-09-06 after an orchestrator audit found the bare form silently
|
|
383
|
+
// invisible to both this demand and the byte/path detectors — a blind
|
|
384
|
+
// instrument). A cmd naming no such script (most test-fixture closes:
|
|
385
|
+
// `true`, `npx`, `pytest`, a relative bare executable — or a
|
|
386
|
+
// missing/malformed cmd, already reded above) carries no demand — a stage
|
|
387
|
+
// with nothing to hash gets no `missing-required` for a field that could
|
|
388
|
+
// never be minted honestly. `validateJob` stays pure (no fs): this checks
|
|
389
|
+
// the FIELD'S SHAPE only, never the bytes on disk (that is the runner's
|
|
390
|
+
// job, `checkCloseByteSignature`/`checkStageByteSignature`).
|
|
391
|
+
const isScriptCmd = closeScriptCandidateToken(o.cmd) !== null;
|
|
392
|
+
if (isScriptCmd) {
|
|
393
|
+
if (o.sha256 === undefined) {
|
|
394
|
+
red('missing-required', `${at}.sha256`, 'hex sha256 of the script file bytes `cmd` names — mint it with signCloseScripts (src/close-integrity.js) / scripts/sign-close.mjs, never hand-typed');
|
|
395
|
+
} else if (typeof o.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.sha256)) {
|
|
396
|
+
red('invalid-value', `${at}.sha256`, '64 lowercase hex chars — sha256 of the script file bytes, never the spec, never the path');
|
|
397
|
+
}
|
|
398
|
+
} else if (o.sha256 !== undefined && (typeof o.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.sha256))) {
|
|
399
|
+
// not demanded for a non-script cmd, but a PRESENT value is still
|
|
400
|
+
// shape-checked — a malformed string is a typo either way
|
|
401
|
+
red('invalid-value', `${at}.sha256`, '64 lowercase hex chars — sha256 of the script file bytes, never the spec, never the path');
|
|
402
|
+
}
|
|
359
403
|
if (!Number.isInteger(o.expect)) red('invalid-value', `${at}.expect`, 'integer exit code');
|
|
360
404
|
// The judgment-rendered signal (PRD v1.11, optional). Exit code alone
|
|
361
405
|
// cannot separate "the suite ran and failed" from "the suite crashed at
|
|
@@ -492,7 +536,7 @@ export function checkMenu(close) {
|
|
|
492
536
|
}
|
|
493
537
|
|
|
494
538
|
/** the fields a close STAGE may carry — anything else is a smuggle channel */
|
|
495
|
-
const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs', 'direction'];
|
|
539
|
+
const STAGE_FIELDS = ['name', 'cmd', 'expect', 'judged', 'gapKeep', 'offer', 'needs', 'direction', 'sha256'];
|
|
496
540
|
|
|
497
541
|
/**
|
|
498
542
|
* Validate the staged close. Every stage is a predicate BODY under the same
|
package/src/plan.js
CHANGED
|
@@ -177,7 +177,11 @@ export { hasNestedQuantifier };
|
|
|
177
177
|
export function stageClose(close) {
|
|
178
178
|
if (Array.isArray(close)) return close;
|
|
179
179
|
if (!isObj(close) || close.type !== 'predicate') return null;
|
|
180
|
-
|
|
180
|
+
// `sha256` (M2, PRD item 27) rides through exactly like every other
|
|
181
|
+
// predicate-body field — a single-object predicate close is byte-signable
|
|
182
|
+
// the same way a staged close's stages are, and `checkCloseByteSignature`
|
|
183
|
+
// reads this field off whatever `closeStagesOf` hands back.
|
|
184
|
+
return [{ name: 'close', cmd: close.cmd, expect: close.expect, judged: close.judged, gapKeep: close.gapKeep, sha256: close.sha256 }];
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
/**
|