bareloop 0.21.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 +167 -0
- package/bareloop.context.md +118 -12
- package/package.json +2 -2
- package/src/authorjob.js +15 -1
- package/src/bundle.js +40 -46
- package/src/cli.js +12 -0
- package/src/close-integrity.js +325 -0
- package/src/closetimeout.js +165 -0
- package/src/index.js +14 -0
- 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 +3 -1
- package/src/reviewdoor.js +26 -6
- package/src/run.js +24 -3
- package/src/validate.js +59 -0
- package/types/close-integrity.d.ts +196 -0
- package/types/closetimeout.d.ts +126 -0
- package/types/index.d.ts +1 -0
- package/types/planrun.d.ts +17 -2
- package/types/ralph.d.ts +19 -5
- package/types/reuse.d.ts +2 -0
- package/types/reviewdoor.d.ts +3 -1
- package/types/run.d.ts +25 -2
- package/types/validate.d.ts +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,173 @@ All notable changes to bareloop are documented here. Format:
|
|
|
5
5
|
[SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
|
|
6
6
|
feature lands, **patch** = docs, fixes, scaffolding.
|
|
7
7
|
|
|
8
|
+
## [0.22.0] — 2026-09-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Close integrity M1** (`docs/product/CLOSE-INTEGRITY-BUILD.md`, PRD item 27, F129/F131).
|
|
13
|
+
- `src/close-integrity.js`: the ONE `close-absolute-path` detector, shared by
|
|
14
|
+
`src/bundle.js` (export time) and a new $0 run-start precheck in `runPlan`
|
|
15
|
+
(`src/planrun.js`) — every job, not only exported bundles, now refuses at $0
|
|
16
|
+
(before the close-first precheck, before any provider call) when a close
|
|
17
|
+
script's CONTENT bakes in an absolute path that exists on disk. Exports
|
|
18
|
+
`readCloseScripts`, `checkCloseAbsolutePaths`, `absolutePathLiteralsOf` from
|
|
19
|
+
`src/index.js` — `readCloseScripts` is the pure reader M2's sha256 fingerprint
|
|
20
|
+
will reuse.
|
|
21
|
+
- `runJob`/`runPlan` gain an optional `resumable` flag (default `true`,
|
|
22
|
+
`run-u.mjs` unchanged); the bundle CLI (`src/cli.js`) passes `resumable: false`
|
|
23
|
+
so its escalation tail says "resume is `run-u`-only in v1" instead of naming a
|
|
24
|
+
`--resume` flag it does not implement (F130).
|
|
25
|
+
|
|
26
|
+
- **Hermetic test runner** (F136, closed at the runner). `scripts/hermetic.mjs`
|
|
27
|
+
runs `node --test` with `HOME` redirected to a fresh empty `mkdtemp` dir and
|
|
28
|
+
`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_NOSYSTEM` set to neutralise git
|
|
29
|
+
config, plus any inherited `GIT_AUTHOR_*`/`GIT_COMMITTER_*`/`EMAIL` deleted — the
|
|
30
|
+
local suite is now blind to this machine's identity the same way CI's runner already
|
|
31
|
+
is, so a test leaning on either fails here first. `npm test` now runs it in place of
|
|
32
|
+
a bare `node --test`; not a sandbox (PATH, `node_modules`, shelled-out binaries
|
|
33
|
+
untouched). See `docs/logs/FINDINGS.md` F136.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **F129, remaining 8 close scripts**: `scripts/testgen-cold-check-close.mjs`,
|
|
38
|
+
`testgen-close.mjs`, `types-close.mjs`, `l2poc-check-close.mjs`,
|
|
39
|
+
`u-bareagent-close.mjs`, `u-bareguard-close.mjs`, `u-litectx-close.mjs`,
|
|
40
|
+
`u-baremobile-close.mjs` now read `WORKDIR = process.cwd()` instead of a
|
|
41
|
+
hardcoded absolute patient path, following `u-spawner-close.mjs`'s fix
|
|
42
|
+
(`3b987d4`). Behaviour-preserving: `run-u` always passes `cwd` = the patient.
|
|
43
|
+
|
|
44
|
+
- **Close integrity M2 — close-bytes signature** (`docs/product/CLOSE-INTEGRITY-BUILD.md`,
|
|
45
|
+
PRD item 27, N4). A job's signature now covers its close scripts' CONTENT, not only
|
|
46
|
+
the path naming them.
|
|
47
|
+
- New signed spec field `close[].sha256`: hex sha256 of the script file's bytes, for
|
|
48
|
+
every stage whose `cmd` names an addressable script (an interpreter cmd —
|
|
49
|
+
`node`/`sh`/`bash`/`python`/`python3`/`npx` — or a bare directly-executable absolute
|
|
50
|
+
path, e.g. a `.sh` wrapper). It sits inside the signed spec, so `jobSpecHash` covers
|
|
51
|
+
it with no hash-function change. `validateJob` (`src/job.js`) demands it
|
|
52
|
+
(`missing-required`) on every addressable stage and shape-checks it
|
|
53
|
+
(`invalid-value`, 64 lowercase hex chars).
|
|
54
|
+
- `src/close-integrity.js` gains `hashCloseScriptBytes`, `checkCloseByteSignature`,
|
|
55
|
+
`checkStageByteSignature`, `signCloseScripts` (all exported from `src/index.js`).
|
|
56
|
+
`runPlan` verifies the signature at run start (alongside `close-absolute-path`, $0,
|
|
57
|
+
before the close-first precheck and before any provider call) AND before EVERY
|
|
58
|
+
close run thereafter (the `runCloseStages` seam the precheck/preflight/check-passes/
|
|
59
|
+
close-fix-loop all share) — a mismatch reds the new typed fault `close-tampered`
|
|
60
|
+
(`src/ralph.js`'s `CLOSE_FAULTS`), distinct from `close-red`/`close-crashed`.
|
|
61
|
+
- `src/bundle.js`'s `exportBundle` reds `close-sha-mismatch` when a spec's signed
|
|
62
|
+
sha256 disagrees with the script bytes actually being packed — the manifest hash
|
|
63
|
+
and the spec's own signature must never drift apart.
|
|
64
|
+
- `scripts/sign-close.mjs`: a thin CLI wrapper around `signCloseScripts` that fills
|
|
65
|
+
`close[].sha256` from disk and prints the old→new `jobSpecHash`; never writes
|
|
66
|
+
unless `--write`. The agent never writes this field — `src/authorjob.js`'s
|
|
67
|
+
`assembleSpec` refuses a draft carrying `sha256` the same way it refuses
|
|
68
|
+
`close`/`closeDecl`/`verdictType`.
|
|
69
|
+
- **Widened scope (orchestrator audit, folded into this build):** the addressable-script
|
|
70
|
+
shape test (`closeScriptCandidateToken`, `src/validate.js`) now also recognizes a
|
|
71
|
+
bare directly-executable absolute-path cmd (no interpreter prefix) — previously
|
|
72
|
+
invisible to BOTH `close-absolute-path` and this fingerprint. Every `jobs/*.json`
|
|
73
|
+
whose close names a script was re-signed with `scripts/sign-close.mjs --all --write`.
|
|
74
|
+
|
|
75
|
+
- **Close integrity M3 — close timeout: autoset + signed override** (PRD item 27,
|
|
76
|
+
hamr 2026-09-06: "both … autoset and can be override, same like api pricing"; arbiter
|
|
77
|
+
constants set 2026-09-07: floor 120,000ms, K = 5).
|
|
78
|
+
- New `src/closetimeout.js`: `CLOSE_TIMEOUT_FLOOR_MS`, `CLOSE_TIMEOUT_K`,
|
|
79
|
+
`TIMING_PREFLIGHT_CEILING_MS`, `timeCloseStages`, `computeCloseTimeoutCeiling`,
|
|
80
|
+
`resolveCloseTimeoutMs`, `closeTimeoutBanner` (all exported from `src/index.js`). A
|
|
81
|
+
$0 timing preflight runs every close stage once, ignoring verdicts, before the
|
|
82
|
+
close-first precheck and before any provider call; the per-stage ceiling every close
|
|
83
|
+
call in the run then uses is `max(FLOOR, K × slowest measured stage)`, printed on
|
|
84
|
+
every run (`close timeout: <n>s per stage (estimated from seed timing: …)`).
|
|
85
|
+
- New OPTIONAL signed spec field `closeTimeoutMs` (`>= CLOSE_TIMEOUT_FLOOR_MS`,
|
|
86
|
+
`src/job.js`): the operator's own override, which wins outright over the estimate and
|
|
87
|
+
may legally sit above OR below it (the rates-passthrough shape, F113 — not
|
|
88
|
+
tighten-only). Skipped when set: the timing pass never runs. Arbiter territory —
|
|
89
|
+
`src/authorjob.js`'s `assembleSpec` refuses a draft carrying it.
|
|
90
|
+
- New `runPlan` run-start check `wall-under-close-timeout`: refuses at $0 when
|
|
91
|
+
`job.maxWallMs` is under the just-resolved EFFECTIVE close timeout — the honest shape
|
|
92
|
+
once the real (autoset or signed) ceiling is known, never a silent clamp.
|
|
93
|
+
- `scripts/run-u.mjs`'s hardcoded `CLOSE_TIMEOUT_MS = 900_000` retires; the per-stage
|
|
94
|
+
ceiling is resolved once (via the same `resolveCloseTimeoutMs`) before the outside
|
|
95
|
+
watchdog (F67) is even spawned, so its stale/grace windows size off the real number.
|
|
96
|
+
`src/cli.js`'s bundle runner passes nothing — both runners now go through the
|
|
97
|
+
identical autoset/override path.
|
|
98
|
+
- **Part B — `BARELOOP_CLOSE_DIR`.** `runClose` (`src/ralph.js`) accepts an optional
|
|
99
|
+
`closeDir` and sets it as `BARELOOP_CLOSE_DIR` in the close's (already-stripped) env —
|
|
100
|
+
a `BARELOOP_` name matches none of `CLOSE_ENV_DENY`'s rules. New
|
|
101
|
+
`checkCloseDirRequired`/`CLOSE_DIR_ENV_VAR` (`src/close-integrity.js`): a close script
|
|
102
|
+
whose source mentions the variable and gets no `closeDir` refuses at $0
|
|
103
|
+
(`close-dir-required`), the same seam as `close-absolute-path`/`close-tampered`. The
|
|
104
|
+
four hand-authored scripts that hardcoded an external `SPINE_DIR`
|
|
105
|
+
(`testgen-cold-check-close.mjs`, `testgen-close.mjs`, `l2poc-check-close.mjs`,
|
|
106
|
+
`types-close.mjs`) now read `process.env.BARELOOP_CLOSE_DIR` and instrument-stop
|
|
107
|
+
(exit 97) when it is absent — no baked fallback. `scripts/u-pulselog-close.mjs`'s
|
|
108
|
+
hardcoded `--workdir` DEFAULT (a separate F129 cwd-class hazard — that literal existed
|
|
109
|
+
on disk and already tripped the shipped `close-absolute-path` guard for
|
|
110
|
+
`jobs/pulselog-u-types.json`) now falls back to `process.cwd()`, the same M1 template
|
|
111
|
+
every other close script uses. `src/cli.js` sets `closeDir` to
|
|
112
|
+
`<bundleDir>/runs/<runid>/close/`; `scripts/run-u.mjs` sets it to its existing
|
|
113
|
+
`spineDir`. Every `jobs/*.json` whose close bytes changed was re-signed
|
|
114
|
+
(`scripts/sign-close.mjs --all --write`).
|
|
115
|
+
|
|
116
|
+
- **F132 — a real paid fire of an exported bundle refused itself `close-tampered`
|
|
117
|
+
at $0** (run `mtqwmb9l` → export → `mtqwydl4`). `exportBundle`'s import rewrite
|
|
118
|
+
(`../src/x.js` → `'bareloop'`) changes a close script's bytes, but the written
|
|
119
|
+
bundle's `spec.json` kept the SOURCE spec's `close[].sha256` unchanged — two
|
|
120
|
+
signatures over two different byte strings, so the run-start
|
|
121
|
+
`checkCloseByteSignature` correctly refused a bundle that was never actually
|
|
122
|
+
tampered with. `exportBundle` (`src/bundle.js`) now re-signs `close[].sha256`
|
|
123
|
+
in the written spec over the RELOCATED bytes it actually packs, for every
|
|
124
|
+
stage that already carries a signature — the export-time `close-sha-mismatch`
|
|
125
|
+
check against the SOURCE spec/bytes is unchanged (it still guards a tampered
|
|
126
|
+
source).
|
|
127
|
+
|
|
128
|
+
- **F133 — double/mislabelled close-timeout banner** (run `mtqwmb9l`). `runPlan`
|
|
129
|
+
printed/emitted "estimated from seed timing" (`scripts/run-u.mjs`'s own
|
|
130
|
+
resolution, needed early to size the outside watchdog, F67) AND, on the same
|
|
131
|
+
run, "explicit runner override" (the library's own print, because run-u also
|
|
132
|
+
fed the resolved number back in as a bare `closeTimeoutMs`) — two lines for
|
|
133
|
+
one number, the second lying about its source. `scripts/run-u.mjs` no longer
|
|
134
|
+
passes `closeTimeoutMs` into `runJob` at all; `runJob`/`runPlan` resolve
|
|
135
|
+
(autoset, or the spec's own signed field) and announce the ceiling themselves
|
|
136
|
+
— the ONE banner/spine record per run. The runtime `closeTimeoutMs` option on
|
|
137
|
+
`runJob`/`runPlan` stays, test/backward-compat only. Same fire, second (real)
|
|
138
|
+
catch: run-u's own pre-fix early `emit('close-timing', …)` landed BEFORE
|
|
139
|
+
`job-start` on the spine (`runJob` is what emits `job-start`, and run-u called
|
|
140
|
+
it before ever calling `runJob`) — every spine reader assumes `job-start`
|
|
141
|
+
opens the file. Fixed by the same change (run-u no longer emits it early);
|
|
142
|
+
`tests/readshim-battery.test.js`'s archive check now tolerates `job-start`
|
|
143
|
+
within the first 3 records (the archived `u-mtqwmb9l.jsonl` is real and never
|
|
144
|
+
rewritten), and a new test pins `job-start` as literally the first record on
|
|
145
|
+
a fresh scripted run.
|
|
146
|
+
|
|
147
|
+
- **F135 — review-door accept re-run bypassed the close byte signature**. Every
|
|
148
|
+
other close execution goes through `checkStageByteSignature` before running
|
|
149
|
+
a stage (`runCloseStages`, `src/planrun.js`); the review door's `accept`
|
|
150
|
+
re-proof (`proveMechanically`, `src/reviewdoor.js`) called
|
|
151
|
+
`runStages`/`runDeclaredStages` directly and skipped it — a close script
|
|
152
|
+
swapped on disk after the run ended but before the signer's accept ran
|
|
153
|
+
unchecked and could mint a false accept. Fixed: `proveMechanically` now
|
|
154
|
+
verifies the mechanical stages' bytes against their signed `sha256` first,
|
|
155
|
+
refusing through the existing `door-accept-red` path on a mismatch
|
|
156
|
+
(`tests/reviewdoor-tamper.test.js`).
|
|
157
|
+
|
|
158
|
+
- **F134 — live re-fire validation, no code change**. Two real paid fires of the
|
|
159
|
+
same exported bundle (`mtr0icky` cap-halt, `mtr4t1u1` green re-fire) confirmed
|
|
160
|
+
M2's relocated-bytes signature and M3's close-timing banner both hold under a
|
|
161
|
+
second real fire, not just the one that motivated each fix; the cap-halt
|
|
162
|
+
itself read as a cap-shaped negative (an `Any`-suppression cheat correctly
|
|
163
|
+
caught by `no-suppressions`, not an instrument defect). See
|
|
164
|
+
`docs/logs/FINDINGS.md` F134.
|
|
165
|
+
|
|
166
|
+
- **F136 — CI-only red: patient test fixtures committed without the neutralised
|
|
167
|
+
git identity**. `tests/close-integrity.test.js` and `tests/close-timeout.test.js`
|
|
168
|
+
each added a second commit on top of `initPatientRepo`'s seed via a raw
|
|
169
|
+
`execFileSync('git', ...)` with no env, so it depended on the host's own git
|
|
170
|
+
identity — green on hamr's machine (a global config papers over it), red on
|
|
171
|
+
CI (none). `tests/helpers.js` now exports `patientGitEnv`/`gitInPatient`
|
|
172
|
+
reusing `initPatientRepo`'s identity env; both files' `makePatient` go through
|
|
173
|
+
it. See `docs/logs/FINDINGS.md` F136.
|
|
174
|
+
|
|
8
175
|
## [0.21.0] — 2026-09-06
|
|
9
176
|
|
|
10
177
|
### Added
|
package/bareloop.context.md
CHANGED
|
@@ -134,7 +134,8 @@ minting claim, or the shell-owned retry cap — all unknown-field reds.
|
|
|
134
134
|
| `conditions` | `{ providerPath?, closeVerbosity?, taskFraming?, scaffold? }` | declared keys only, string values — the environment label (consumed by the N3 lineage key; recorded on spines from run one) |
|
|
135
135
|
| `cadence` | `{ unit: hour\|day\|week, every: 1..30 }` | validated now, consumed at N5 (Scheduler) |
|
|
136
136
|
| `budgetUsd` | `0 < n <= shell cap` | ceiling chain: workflow ≤ job ≤ shell — each layer may tighten, never exceed |
|
|
137
|
-
| `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage number, so a spec with many stages can validate while its overshoot dwarfs its cap:
|
|
137
|
+
| `maxWallMs` | optional integer ms `>= MIN_WALL_MS` (one close timeout) | the run's wall clock. **NO DEFAULT, by ruling** — absent means time-unbounded *by explicit operator choice*, never by fallback (F45: a defaulted cap is a silent second ceiling). Enforcement is a BETWEEN-ROUND deadline, so the honest worst case is `maxWallMs + closeStages × closeTimeoutMs` — every stage of a staged close gets the FULL timeout — and all three numbers are reported (`loop.stop()` cannot cut an in-flight call — F61 measured 500ms→4,018ms). The `MIN_WALL_MS` floor is a ONE-stage, un-autoset-default number, so a spec with many stages OR a raised/autoset `closeTimeoutMs` can validate while its overshoot dwarfs its cap: `runPlan` adds a RUN-START check (PRD item 27/M3) that refuses `wall-under-close-timeout` at $0 once the EFFECTIVE per-stage ceiling is known (autoset or signed), rather than a silent clamp. Operator-only, tighten-only; adding or changing it changes the spec hash |
|
|
138
|
+
| `closeTimeoutMs` | optional integer ms `>= CLOSE_TIMEOUT_FLOOR_MS` (120,000 — hamr's arbiter floor, 2026-09-07) | PRD item 27/M3's SIGNED override for the close's own per-stage wall-clock cap. Absent (the normal case): the run autosets it from a $0 seed timing pass — `max(FLOOR, K × slowest measured stage)`, `K = 5` (hamr, 2026-09-07: "2 and 5 are fine"). Present: the operator's own number wins outright and may legally sit ABOVE *or below* the autoset estimate (the rates-passthrough shape, F113 — never tighten-only). Arbiter territory exactly like `budgetUsd`/`maxWallMs`/`close[].sha256`: the authoring pipeline's `assembleSpec` refuses a draft carrying it |
|
|
138
139
|
| `model` | optional non-empty string, exact provider model id (e.g. `"claude-sonnet-5"`) | the WORKER's model, build-list #3. Absent means today's runner default; present it wins over a `--model` flag outright, and a flag naming a different id is refused (`resolveWorkerModel`, `src/job.js`) rather than silently overridden. Part of the signed hash like every other field. Worker only — the judge model stays library-pinned (`JUDGE_MODEL`) pending recalibration |
|
|
139
140
|
| `writeScope` | array of contained globs | the operator's outer fence; the plan's own scopes must fit inside it, same containment code |
|
|
140
141
|
| `steps` | RETIRED | operator-authored `steps[]` was deleted (PRD v1.32); a spec carrying it reds `shape-retired:steps` by name rather than half-running |
|
|
@@ -391,7 +392,7 @@ Append-only JSONL event emitter bound to one file. `seq` monotonic per spine, `t
|
|
|
391
392
|
last. Consumers are pure listeners; nothing reads the file back. Returns each event as
|
|
392
393
|
written.
|
|
393
394
|
|
|
394
|
-
### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites? })` → `'green' | 'escalated'` — `src/ralph.js`
|
|
395
|
+
### `ralph({ middle, close?, judge?, capRuns?, ladder?, emit, redact?, closeTimeoutMs?, cwd?, expect?, judged?, gapKeep?, workerWrites?, closeDir? })` → `'green' | 'escalated'` — `src/ralph.js`
|
|
395
396
|
|
|
396
397
|
The dumb outer shell: `while close-red and not exhausted: run the middle`. **Exhaustion has
|
|
397
398
|
two alternative rules and you supply exactly one** — `capRuns` (a fixed number of middle runs)
|
|
@@ -422,11 +423,23 @@ set the close's `gapKeep` (job-spec, arbiter territory) to a regex like `"^not o
|
|
|
422
423
|
matching failure lines are preserved in a capped kept-failures block regardless of where
|
|
423
424
|
they print (F28: the first real firing delivered a gap with zero failure names). The gap
|
|
424
425
|
also combines stdout+stderr, so a stdout failure survives stderr noise.
|
|
425
|
-
`closeTimeoutMs` caps the close's wall clock (default 120s
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
426
|
+
`closeTimeoutMs` caps the close's wall clock (default 120s when nothing else supplies a
|
|
427
|
+
value — `runClose`'s own bare default) — shell/operator territory, inexpressible in any
|
|
428
|
+
config. A faulted close is asked to leave with SIGTERM and, if it is still there 2s later,
|
|
429
|
+
ended with SIGKILL: SIGTERM is a request a close can decline (its own handler, a trapping
|
|
430
|
+
wrapper), and without a second deadline the wait never resolves. The verdict is untouched —
|
|
431
|
+
the FIRST fault named the outcome and the kill only enforces it. **As of PRD item 27/M3, a
|
|
432
|
+
direct `ralph`/`runClose`/`runStages` caller still owns this number outright (`closeTimeoutMs
|
|
433
|
+
?? 120_000`); the AUTOSET/override resolution lives one level up, in `runPlan`/`runJob`
|
|
434
|
+
(see `resolveCloseTimeoutMs` below) — `ralph` itself never runs a timing pass.**
|
|
435
|
+
|
|
436
|
+
`closeDir` (PRD item 27/M3 Part B, optional) is the close's own BOOKS DIRECTORY — pristine
|
|
437
|
+
copies, calibration thresholds, logs — deliberately outside the patient tree (the worker
|
|
438
|
+
must never read the arbiter's books, v1.12). Forwarded to every close invocation as the
|
|
439
|
+
env var `BARELOOP_CLOSE_DIR` (set on a COPY of `process.env`, after `CLOSE_ENV_DENY`
|
|
440
|
+
stripping — a `BARELOOP_` name matches none of that denylist's rules and is never stripped).
|
|
441
|
+
A close script that reads this variable and gets none must instrument-stop itself; nothing
|
|
442
|
+
in the runner invents a fallback directory.
|
|
430
443
|
|
|
431
444
|
**The close child's environment is stripped of credential-shaped variables before it runs**
|
|
432
445
|
(`CLOSE_ENV_DENY` in `src/ralph.js`: provider keys, `AWS_*`, any name ending
|
|
@@ -958,13 +971,33 @@ Reserved spine vocabulary (V7, machinery-free until job #1 surfaces one):
|
|
|
958
971
|
`coordination-red` — a failure between units (scope contention, step order, store
|
|
959
972
|
races), never to be folded into worker/interpreter reds.
|
|
960
973
|
|
|
961
|
-
### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, layerRoot?, readShim?, scout?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun? })` → outcome — `src/run.js`
|
|
974
|
+
### `runJob(spec, { approvals, workdir, provider, nativeProvider?, providerFor?, emit, capRuns?, strikeLimit?, shellCapUsd?, closeTimeoutMs?, closeDir?, layerRoot?, readShim?, scout?, bridge?, priorSpentUsd?, priorSpendComplete?, priorWallMs?, resumeSeed?, resumeGrades?, resumeReplans?, resumeBranch?, humanRuling?, heldRuling?, reviewDoor?, doorRerun?, resumable? })` → outcome — `src/run.js`
|
|
975
|
+
|
|
976
|
+
**`closeTimeoutMs` (PRD item 27/M3) is now OPTIONAL for every real caller.** Omit it (both
|
|
977
|
+
`src/cli.js`'s bundle runner and `scripts/run-u.mjs` do) and the run AUTOSETS the per-stage
|
|
978
|
+
close ceiling from a $0 seed timing pass, or reads the job spec's own signed override —
|
|
979
|
+
"guesstimate + loud sign + customer override", the same shape rates-passthrough already
|
|
980
|
+
uses (F113). Passing an explicit value here still wins outright over both (kept for
|
|
981
|
+
backward compatibility and test control) — see `resolveCloseTimeoutMs` further down.
|
|
982
|
+
|
|
983
|
+
**`closeDir` (PRD item 27/M3 Part B, optional but effectively required for any
|
|
984
|
+
hand-authored close that reads its own books)** — the close's own directory (pristine
|
|
985
|
+
copies, calibration thresholds, logs), outside the patient tree. `src/cli.js` sets it to
|
|
986
|
+
`<bundleDir>/runs/<runid>/close/`; `scripts/run-u.mjs` sets it to its existing `spineDir`. A
|
|
987
|
+
close script whose source mentions `BARELOOP_CLOSE_DIR` and gets none here refuses the run
|
|
988
|
+
at $0 (`close-dir-required`, before any provider call) — see `checkCloseDirRequired` below.
|
|
962
989
|
|
|
963
990
|
The last seven are the RESUME fold and are documented under *Resuming a killed run* below; they
|
|
964
991
|
default to `0` / `true` / `0` / `null` / `[]` / `null` / `null`, so a fresh run passes none of them.
|
|
965
992
|
Three of them are folds of a bound the operator SIGNED (money, wall, replans) and one is a
|
|
966
993
|
readout seed (grades) — the distinction matters and is spelled out there.
|
|
967
994
|
|
|
995
|
+
`resumable` (default `true`, PRD item 27(c)/F130) says whether THIS runner supports
|
|
996
|
+
`--resume` at all — `run-u.mjs` leaves it at the default (byte-identical to before this
|
|
997
|
+
flag existed); the exported bundle CLI (`src/cli.js`) passes `false` so the run's
|
|
998
|
+
escalation tail says "resume is `run-u`-only in v1" instead of naming a flag it does not
|
|
999
|
+
implement.
|
|
1000
|
+
|
|
968
1001
|
The runner — the shell's top layer, and the ONE entry. It composes everything below it and
|
|
969
1002
|
interprets nothing itself. Sequence: **approval gate** (human-signs-always — refuses an
|
|
970
1003
|
unapproved spec before ANY provider call: `unapproved-spec`) → **primitive smoke** (litectx
|
|
@@ -1601,7 +1634,7 @@ package makes). Which runs open one is `doorOpens(job, reviewDoor)`:
|
|
|
1601
1634
|
territory.
|
|
1602
1635
|
|
|
1603
1636
|
**Answering one — `answerReviewDoor({ job, workdir, events, decision, text?, closeTimeoutMs?,
|
|
1604
|
-
registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
|
|
1637
|
+
closeDir?, registryDir?, name?, runid?, at?, now?, emit? })`** (`src/reviewdoor.js`). The answer arrives
|
|
1605
1638
|
after the run has ended, possibly days later and from another process, so it cannot ride the
|
|
1606
1639
|
run's return path. This is that seam. It never throws and never conjures — a fourth door, an
|
|
1607
1640
|
empty `rerun`, a run that opened no door (`no-door`), an expired one (`door-expired`), a tree
|
|
@@ -2481,7 +2514,9 @@ nothing here to check it against).
|
|
|
2481
2514
|
|
|
2482
2515
|
Enforcement is **local only** — wired into `npm test` (see `package.json`'s `test`
|
|
2483
2516
|
script), never into `.github/workflows/*` (CI already runs `npm test`, so the rule is
|
|
2484
|
-
enforced there without an ask-first CI edit).
|
|
2517
|
+
enforced there without an ask-first CI edit). `npm test` itself now runs `node --test`
|
|
2518
|
+
through `scripts/hermetic.mjs`, which strips this machine's `HOME`/git identity so a
|
|
2519
|
+
test that leans on either fails locally instead of only on CI (F136). The check itself lives outside the
|
|
2485
2520
|
published package, at `scripts/prompt-commit-check.mjs` (pure decision logic in
|
|
2486
2521
|
`scripts/promptcommitlib.mjs`, `scripts/` is not in `package.json`'s `files`, so none of
|
|
2487
2522
|
this ships):
|
|
@@ -2546,11 +2581,77 @@ written by `bareloop run`, step 6 below) is real and live but is not in the froz
|
|
|
2546
2581
|
layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approveHash`
|
|
2547
2582
|
(the POC-fact correction, below) beyond the fields the original layout table named.
|
|
2548
2583
|
|
|
2584
|
+
#### `src/close-integrity.js` — close-absolute-path (M1) AND close-bytes signature (M2)
|
|
2585
|
+
|
|
2586
|
+
Shared by `src/bundle.js` (export time) and a $0 run-start precheck inside `runPlan`
|
|
2587
|
+
(`src/planrun.js`) — every job, not only exported bundles, refuses at $0 (before the
|
|
2588
|
+
close-first precheck, before any provider call) when a close script's CONTENT bakes in an
|
|
2589
|
+
absolute path that exists on disk. Two spellings of the same rule is exactly how F129
|
|
2590
|
+
shipped an export-only guard while every non-exported job stayed exposed.
|
|
2591
|
+
|
|
2592
|
+
**Scope (widened 2026-09-06, PRD item 27/M2, an orchestrator audit):** a close script is
|
|
2593
|
+
addressable — visible to `readCloseScripts`, `checkCloseAbsolutePaths`, the sha256
|
|
2594
|
+
fingerprint, and `signCloseScripts` — when its `cmd` is EITHER an interpreter
|
|
2595
|
+
(`node`/`sh`/`bash`/`python`/`python3`/`npx`) followed by a non-empty argv[1], OR a bare
|
|
2596
|
+
cmd whose argv[0] ITSELF is an absolute path (a `.sh`/other directly-executable close
|
|
2597
|
+
script, no interpreter prefix — e.g. `jobs/aurora-testgen-cold.json`'s close). A relative
|
|
2598
|
+
bare executable (`true`, `pytest`, `npm test`) names no file at all and stays out of
|
|
2599
|
+
scope, on both halves, exactly as before. The shape test itself
|
|
2600
|
+
(`closeScriptCandidateToken`, `src/validate.js`) is shared with `src/job.js`'s sha256
|
|
2601
|
+
demand so the demand and the detectors can never drift apart about which stages are in
|
|
2602
|
+
scope.
|
|
2603
|
+
|
|
2604
|
+
| function | args → returns | notes |
|
|
2605
|
+
|---|---|---|
|
|
2606
|
+
| `absolutePathLiteralsOf(source)` | close script text → `string[]` | the scan itself (moved here from `src/bundle.js`, which now imports it) |
|
|
2607
|
+
| `readCloseScripts(spec, cwd)` | resolved job spec + cwd → `{ stage, path, bytes, sha256 }[]` | resolves every addressable `close[].cmd` (see scope above) to its absolute path and bytes off disk; a relative path resolves against `cwd`. Never throws — a missing/unreadable script reports `bytes: null`. `sha256` (M2, additive) is the stage's own signed field verbatim, or `null` |
|
|
2608
|
+
| `checkCloseAbsolutePaths(spec, cwd)` | resolved job spec + cwd → `{ ok: true } \| { ok: false, reds: { stage, path, literal }[] }` | the run-start check `runPlan` calls before the close-first precheck |
|
|
2609
|
+
| `hashCloseScriptBytes(text)` | script text → sha256 hex | the ONE formula every M2 consumer uses (minting, the run-start check, the runtime re-verify, the export mismatch check) — hashing the utf8-decoded text agrees with hashing the raw file bytes because a close script is always valid UTF-8 |
|
|
2610
|
+
| `checkCloseByteSignature(spec, cwd)` | resolved job spec + cwd → `{ ok: true } \| { ok: false, reds: { stage, path, expected, actual }[] }` | the $0 run-start integrity check (PRD item 27): every signed `close[].sha256` must match the script's bytes off disk RIGHT NOW. `runPlan` calls this alongside `checkCloseAbsolutePaths`, before the close-first precheck and before any provider call — a mismatch reds `close-tampered`. An unreadable signed script reads `actual: null` and is STILL a red (never silently "fine") |
|
|
2611
|
+
| `checkStageByteSignature(stages, cwd)` | a close stage chain + cwd → same shape as above | the narrower re-verify `runPlan`'s `runCloseStages` wrapper runs before EVERY close execution (precheck, preflight, check-passes, and the close-fix loop all go through this one seam) — a mismatch short-circuits to a `close-tampered` verdict in the same shape `runClose`/`runStages` render, so it rides out through the existing `CLOSE_FAULTS` machinery exactly like `crashed`/`timed-out`/`killed` |
|
|
2612
|
+
| `signCloseScripts(spec, cwd)` | resolved job spec + cwd → `{ ok, spec, changes: {stage,path,old,new}[], missing: {stage,path}[] }` | the PURE minting helper: fills `close[].sha256` from disk for every addressable stage. Never writes anything itself — `scripts/sign-close.mjs` is the thin CLI wrapper that gates on `--write`. A `closeDecl` spec (no script path to sign) returns `ok:false` unchanged |
|
|
2613
|
+
| `checkCloseDirRequired(spec, cwd, closeDir)` | resolved job spec + cwd + the runner's `closeDir` (or `null`) → `{ ok: true } \| { ok: false, reds: { stage, path }[] }` | PRD item 27/M3 Part B: scans the same addressable-script bytes `readCloseScripts` reads for the literal substring `BARELOOP_CLOSE_DIR` — a script that mentions it needs a real `closeDir` or it would run against `undefined`. A script that never mentions it carries no demand |
|
|
2614
|
+
| `CLOSE_DIR_ENV_VAR` | `'BARELOOP_CLOSE_DIR'` | the ONE spelling `checkCloseDirRequired`'s scan and `runClose`'s env-set (`src/ralph.js`) both use |
|
|
2615
|
+
|
|
2616
|
+
#### `src/closetimeout.js` — close timeout: autoset + signed override (M3)
|
|
2617
|
+
|
|
2618
|
+
hamr's ruling (2026-09-06): *"both … autoset and can be override, same like api pricing"*
|
|
2619
|
+
— the guesstimate-plus-loud-sign-plus-customer-override shape (F113), never a hidden
|
|
2620
|
+
defaulted knob. Arbiter constants, hamr 2026-09-07 verbatim: **floor = 120,000 ms; K = 5
|
|
2621
|
+
("2 and 5 are fine")** — never agent- or adopter-settable.
|
|
2622
|
+
|
|
2623
|
+
| export | shape | notes |
|
|
2624
|
+
|---|---|---|
|
|
2625
|
+
| `CLOSE_TIMEOUT_FLOOR_MS` | `120_000` | the floor, hamr's arbiter constant |
|
|
2626
|
+
| `CLOSE_TIMEOUT_K` | `5` | the multiplier, hamr's arbiter constant |
|
|
2627
|
+
| `LIBRARY_DEFAULT_CLOSE_TIMEOUT_MS` | `120_000` | `runClose`'s own un-autoset default (`src/ralph.js`'s `timeoutMs = 120_000`) — named separately from the floor above even though the two numbers agree today, because they are different claims |
|
|
2628
|
+
| `TIMING_PREFLIGHT_CEILING_MS` | `600_000` (library default × K) | the GENEROUS provisional ceiling every stage runs under DURING the timing pass itself — never the autoset/override ceiling the run then uses, which is only known after the pass completes |
|
|
2629
|
+
| `timeCloseStages(stages, {cwd, redact})` | → `{ perStage: {name,ms,exitCode,timedOut}[], slowestMs, slowestName, anyTimedOut }` | runs EVERY stage once, ignoring verdicts (first-red-wins stays for the real verdict precheck) — a declared/kind stage (no `.cmd`) is skipped, contributing no reading rather than a false zero. A stage that never finishes within `TIMING_PREFLIGHT_CEILING_MS` reads `timedOut: true` |
|
|
2630
|
+
| `computeCloseTimeoutCeiling({slowestMs, overrideMs?})` | → `{ ceilingMs, source: 'estimated'\|'override' }` | `max(FLOOR, K × slowestMs)`, unless `overrideMs` is given, in which case it wins outright (may sit above OR below the estimate — not tighten-only) |
|
|
2631
|
+
| `resolveCloseTimeoutMs({job, stages, cwd, redact})` | → `{ closeTimeoutMs, source, timing, timedOut }` | the ONE resolver `src/planrun.js` and `scripts/run-u.mjs` BOTH call (never two independent spellings of this precedence): a signed `job.closeTimeoutMs` wins with NO timing pass run at all; absent, the pass runs and the ceiling autosets. `timedOut: true` means a stage never finished the pass — the caller reds `close-timing-red` rather than trusting any number |
|
|
2632
|
+
| `closeTimeoutBanner({ceilingMs, source, slowestMs?, slowestName?})` | → the printed line | ONE spelling for `estimated`/`override`/`explicit` (the last is the pre-M3 shell/test runtime-option escape hatch, never the signed field) — printed on every run so a silent default can never hide behind this feature the way the OLD 120s/900s defaults did |
|
|
2633
|
+
|
|
2634
|
+
**`scripts/sign-close.mjs`** — `node scripts/sign-close.mjs <jobs/x.json> [--write]` or
|
|
2635
|
+
`--all` for every `jobs/*.json`. Prints the old→new `jobSpecHash` and each stage's
|
|
2636
|
+
old→new sha256; NEVER writes unless `--write` is passed (a dry run is the default). The
|
|
2637
|
+
agent never runs this with `--write` on its own authority — minting a close-bytes
|
|
2638
|
+
signature is arbiter territory exactly like a budget or a wall clock, the same as the
|
|
2639
|
+
authoring pipeline's own refusal (`src/authorjob.js`'s `assembleSpec`, which refuses a
|
|
2640
|
+
draft carrying `sha256` the same way it refuses `close`/`closeDecl`/`verdictType`).
|
|
2641
|
+
|
|
2642
|
+
**`close[].sha256`** — a signed spec field (PRD item 27/M2): hex sha256 of the bytes of
|
|
2643
|
+
the script file the stage's `cmd` names (only for an addressable cmd, per the scope
|
|
2644
|
+
above). `validateJob` (`src/job.js`) demands it as `missing-required` on every addressable
|
|
2645
|
+
stage and shape-checks it (`invalid-value`, 64 lowercase hex chars) even where not
|
|
2646
|
+
demanded. It sits INSIDE the signed spec, so `jobSpecHash` covers it with no
|
|
2647
|
+
hash-function change — re-signing a job (minting or changing this field) moves the spec
|
|
2648
|
+
hash and needs re-approval, exactly like any other semantic edit.
|
|
2649
|
+
|
|
2549
2650
|
#### `src/bundle.js` — pure, no provider/process calls
|
|
2550
2651
|
|
|
2551
2652
|
| function | args → returns | notes |
|
|
2552
2653
|
|---|---|---|
|
|
2553
|
-
| `exportBundle(o)` | `{ spec, closeScripts, registryDir, outDir, bareloopVersion }` → `{ ok, reds, dir, bundleHash, manifest }` | mints the directory above. Every check runs before anything touches disk — a red means nothing was written |
|
|
2654
|
+
| `exportBundle(o)` | `{ spec, closeScripts, registryDir, outDir, bareloopVersion }` → `{ ok, reds, dir, bundleHash, manifest }` | mints the directory above. Every check runs before anything touches disk — a red means nothing was written. **F132:** a stage carrying a signed `close[].sha256` gets it RE-SIGNED in the written `spec.json` over the RELOCATED bytes (post import-rewrite) it actually packs — not copied verbatim from the source spec. Without this, a script whose import gets rewritten (`../src/x.js` → `'bareloop'`) ships with a signature that covers the wrong bytes, and the bundle refuses itself as `close-tampered` at $0 on its very first run (the manifest hash and the spec signature disagree over two different byte strings for the same file) |
|
|
2554
2655
|
| `bundleHash(dir)` | `dir` → sha256 hex | recomputes from what is on disk right now; throws only if `dir` itself is not a directory |
|
|
2555
2656
|
| `readBundle(dir)` | `dir` → `{ ok, reds, spec, manifest, bridges, blessing, history }` | never throws; every failure is a typed red. Runs the tamper check (below) whenever both a manifest and a spec parsed |
|
|
2556
2657
|
| `resolveBundleSpec(bundle, bundleDir)` | a `readBundle` result + the bundle dir → `{ spec, approveHash }` | the in-memory `$BARELOOP_BUNDLE` → absolute `bundleDir` substitution; `approveHash` is `jobSpecHash` of the *substituted* spec, never the unresolved one |
|
|
@@ -2572,7 +2673,12 @@ layout table at all. `history.jsonl`'s row also carries `bundleHash` and `approv
|
|
|
2572
2673
|
| `close-script-collision` | `exportBundle` | two different close script paths share one basename — a flat `close/` directory would silently clobber one |
|
|
2573
2674
|
| `close-import-unparsed` | `exportBundle` | a close script's import is a shape this module cannot verify at all — default import, `* as ns`, mixed default+named/namespace, a bare side-effect import, a dynamic `import()`, or a relative import that does not point into `src/` (a sibling file never ships in `close/`). Fails safe rather than silently accepting what it cannot read |
|
|
2574
2675
|
| `close-import-unexported` | `exportBundle` | a close script imports a name from bareloop's own `src` that `src/index.js` does not export |
|
|
2575
|
-
| `close-absolute-path` | `exportBundle` (F129) | a close script's source bakes in a quoted string literal that is an absolute POSIX path which `existsSync` finds real on
|
|
2676
|
+
| `close-absolute-path` | `exportBundle` (F129) AND `runPlan` (PRD item 27, every job) | a close script's source bakes in a quoted string literal that is an absolute POSIX path which `existsSync` finds real on this machine and which is not under an allow-listed system prefix (`/usr/`, `/bin/`, `/sbin/`, `/lib/`, `/lib64/`, `/dev/`, `/etc/`, `/proc/`, `/sys/`, `/opt/`). **A close judges `process.cwd()` — the runner's cwd — never a path baked into the script (F8/F129):** the live defect this catches is a hardcoded `WORKDIR` making a close judge the ORIGINAL patient checkout instead of the fresh worktree it was actually pointed at, minting a fake `already-green` at $0. A nonexistent absolute-looking literal is a NAME, not a proven hazard, and is not flagged — the check is deliberately monotone and simple, not clever (see `absolutePathLiteralsOf`'s JSDoc, `src/close-integrity.js`, for its named limits: string concatenation and cross-machine-only paths are invisible to it). `runPlan` reds this at run start, before the close-first precheck and before any provider call — $0, nothing spent |
|
|
2677
|
+
| `close-sha-mismatch` | `exportBundle` (PRD item 27/M2) | a `close[].sha256` the spec carries does not match the bytes of the script actually being packed into the bundle — the two signatures (this one, and `bundleHash`'s manifest hash over the rewritten script) cover the same bytes by two different paths and must never drift apart silently (N4's own hazard, one layer up). Only fires when a value is PRESENT and wrong; a spec with no sha256 at all reds `missing-required` upstream, at `validateJob` |
|
|
2678
|
+
| `close-tampered` | `runPlan` (PRD item 27/M2) | a close script's bytes no longer match its signed `sha256` — checked at run start (alongside `close-absolute-path`, before the close-first precheck and before any provider call) AND before EVERY close run thereafter (`runCloseStages`'s wrapper in `src/planrun.js`, the one seam the precheck/preflight/check-passes/close-fix-loop all share). Distinct from `close-red` (a judged "no" from a working close) and from `close-crashed`/`close-timeout`/`close-killed` (the close ran and hit an instrument fault): this is decided BEFORE the close is even spawned, and it means the close that ran would not have been the one the operator approved. Registered in `CLOSE_FAULTS` (`src/ralph.js`) so it rides out through the same forbidden-zone machinery as every other close fault — never retried, never fed back as a gap |
|
|
2679
|
+
| `close-dir-required` | `runPlan` (PRD item 27/M3 Part B) | a close script's source mentions `BARELOOP_CLOSE_DIR` (it reads its own books directory) but the runner was given no `closeDir` at all — `checkCloseDirRequired` (`src/close-integrity.js`) reuses the same `readCloseScripts` reader the other two integrity checks share. $0, run start, before the close-first precheck and before any provider call |
|
|
2680
|
+
| `close-timing-red` | `runPlan` (PRD item 27/M3) | the $0 timing preflight (`resolveCloseTimeoutMs`/`timeCloseStages`, `src/closetimeout.js`) ran every close stage once, ignoring verdicts, and at least one never finished within the provisional ceiling (`TIMING_PREFLIGHT_CEILING_MS`, the library default × K = 600s) — the close cannot run on this machine in a boundable time. Only reachable when NEITHER a signed `job.closeTimeoutMs` NOR a caller-passed runtime override is present (both skip the pass entirely) |
|
|
2681
|
+
| `wall-under-close-timeout` | `runPlan` (PRD item 27/M3) | `job.maxWallMs` is set and is under the EFFECTIVE per-stage close timeout just resolved (autoset or signed) — a budget under one close cannot fund its own close. `validateJob`'s own `MIN_WALL_MS` floor only bounds the un-autoset library default; this run-start check is the honest shape once the real ceiling is known, rather than a silent clamp |
|
|
2576
2682
|
| `bundle-missing` | `readBundle` | `dir` is not a directory at all |
|
|
2577
2683
|
| `manifest-invalid` / `spec-invalid` | `readBundle` | `manifest.json`/`spec.json` could not be read or parsed |
|
|
2578
2684
|
| `bundle-tampered` | `readBundle` | manifest and spec both parsed, but the recomputed `bundleHash` (from what's really on disk) does not match the stored one — **the load-bearing check, N4 below** |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bareloop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "node
|
|
23
|
+
"test": "node scripts/hermetic.mjs && node scripts/prompt-commit-check.mjs --range origin/main..HEAD",
|
|
24
24
|
"typecheck": "tsc --noEmit",
|
|
25
25
|
"build:types": "tsc",
|
|
26
26
|
"prepublishOnly": "npm run build:types"
|
package/src/authorjob.js
CHANGED
|
@@ -654,7 +654,21 @@ export async function authorCloseForJob({
|
|
|
654
654
|
/** The fields `assembleSpec` writes, and therefore the fields a DRAFT must not
|
|
655
655
|
* carry. Named as data so the refusal and the fold can never disagree about
|
|
656
656
|
* which half of the spec is which. */
|
|
657
|
-
|
|
657
|
+
// `sha256` (PRD item 27/M2) rides alongside `close`/`closeDecl`/`verdictType`:
|
|
658
|
+
// it is arbiter territory exactly like a budget or a wall clock — the agent
|
|
659
|
+
// never mints a close-bytes signature (only `signCloseScripts`/hamr's own
|
|
660
|
+
// re-sign does), so a draft carrying the field at all is refused the same
|
|
661
|
+
// way one carrying a raw `close` array is. A `closeDecl` spec has no script
|
|
662
|
+
// path to sign in the first place (M1's own kind executor, never a shipped
|
|
663
|
+
// script), so this can only ever fire on a smuggled/legacy-shaped draft —
|
|
664
|
+
// named here anyway, because "arbiter fields are refused by name" is the
|
|
665
|
+
// rule, not "arbiter fields that are currently reachable are refused".
|
|
666
|
+
// `closeTimeoutMs` (PRD item 27/M3) joins the same list for the same reason:
|
|
667
|
+
// it is arbiter territory exactly like the timeout floor/multiplier
|
|
668
|
+
// themselves — the agent never mints a close-timeout override (only the
|
|
669
|
+
// operator's own signed spec edit does), so a draft carrying it is refused
|
|
670
|
+
// the same way one carrying a raw `close` array is.
|
|
671
|
+
export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdictType', 'sha256', 'closeTimeoutMs']);
|
|
658
672
|
|
|
659
673
|
/**
|
|
660
674
|
* Fold an authored close into the OPERATOR's own half of the spec.
|
package/src/bundle.js
CHANGED
|
@@ -27,6 +27,7 @@ import { jobSpecHash } from './job.js';
|
|
|
27
27
|
import { loadRegistry } from './bridges.js';
|
|
28
28
|
import { closeStagesOf } from './plan.js';
|
|
29
29
|
import { shapeForkName } from './reuse.js';
|
|
30
|
+
import { absolutePathLiteralsOf, hashCloseScriptBytes } from './close-integrity.js';
|
|
30
31
|
// The live export list IS `src/index.js`'s own `Object.keys` — never a second,
|
|
31
32
|
// hand-kept copy that can drift from the real surface a close script can
|
|
32
33
|
// legally import. This module is itself exported from `index.js` (a cycle by
|
|
@@ -128,50 +129,10 @@ function isRelative(spec) {
|
|
|
128
129
|
return spec.startsWith('./') || spec.startsWith('../');
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// A close script is entitled to name a real system path (`/usr/bin/env`,
|
|
137
|
-
// `/etc/hosts`, …) without that being the F8/F129 hazard — only a path this
|
|
138
|
-
// bundle would actually relocate (a patient checkout, a scratch dir) is.
|
|
139
|
-
const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib64/', '/dev/', '/etc/', '/proc/', '/sys/', '/opt/'];
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* F8/F129 — a close judges the cwd the runner gives it, never a path baked
|
|
143
|
-
* into the script itself (`src/ralph.js`'s `cwd` is load-bearing precisely
|
|
144
|
-
* because every close script in this repo's OWN test suite named an
|
|
145
|
-
* absolute path, so cwd never mattered there — see F8). This scans every
|
|
146
|
-
* quoted string literal in a close script's source for an absolute POSIX
|
|
147
|
-
* path (`/…`) that EXISTS on the exporting machine right now and is not
|
|
148
|
-
* under an allow-listed system prefix.
|
|
149
|
-
*
|
|
150
|
-
* Deliberately monotone and simple, not clever, with named limits: a
|
|
151
|
-
* nonexistent absolute-looking string is treated as a NAME, not a path —
|
|
152
|
-
* `existsSync` is the only oracle this has for "load-bearing", so it is not
|
|
153
|
-
* flagged; a path built by string concatenation (never one whole literal)
|
|
154
|
-
* is invisible to this scan; and a literal that only exists inside a `//` or
|
|
155
|
-
* `/* *\/` comment is naturally excluded because a comment carries no quote
|
|
156
|
-
* characters of its own around the path text.
|
|
157
|
-
* @param {string} source close script text
|
|
158
|
-
* @returns {string[]} the offending literal path values, in source order
|
|
159
|
-
*/
|
|
160
|
-
function absolutePathLiteralsOf(source) {
|
|
161
|
-
/** @type {string[]} */
|
|
162
|
-
const hits = [];
|
|
163
|
-
for (const m of source.matchAll(STRING_LITERAL_RE)) {
|
|
164
|
-
const isBacktick = m[3] !== undefined;
|
|
165
|
-
const value = m[1] ?? m[2] ?? m[3];
|
|
166
|
-
if (value === undefined) continue;
|
|
167
|
-
if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
|
|
168
|
-
if (!value.startsWith('/')) continue;
|
|
169
|
-
if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
|
|
170
|
-
if (!existsSync(value)) continue; // a nonexistent path is a name, not a hazard
|
|
171
|
-
hits.push(value);
|
|
172
|
-
}
|
|
173
|
-
return hits;
|
|
174
|
-
}
|
|
132
|
+
// F8/F129 — `absolutePathLiteralsOf` (a close judges the cwd the runner
|
|
133
|
+
// gives it, never a path baked into the script itself) now lives in
|
|
134
|
+
// `src/close-integrity.js`, shared with the run-start precheck that applies
|
|
135
|
+
// the SAME rule to every job, not only exports. Never a second spelling.
|
|
175
136
|
|
|
176
137
|
/**
|
|
177
138
|
* Every import in a close script this bundle has an opinion about: a
|
|
@@ -378,6 +339,20 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
|
|
|
378
339
|
for (const lit of absolutePathLiteralsOf(source)) {
|
|
379
340
|
red('close-absolute-path', at, `${basename(scriptPath)}: bakes in the absolute path "${lit}" — a close judges the cwd the runner gives it, never a path baked into the script (F8/F129)`);
|
|
380
341
|
}
|
|
342
|
+
// PRD item 27/M2 — the spec's OWN sha256 (if it carries one) must agree
|
|
343
|
+
// with the bytes actually being packed into the bundle: the two
|
|
344
|
+
// signatures (this one, and `bundleHash`'s manifest hash over the
|
|
345
|
+
// rewritten script) cover the same bytes by two different paths, and N4
|
|
346
|
+
// is exactly the hazard of letting a spec's signature drift from what it
|
|
347
|
+
// names. A spec with no sha256 at all is a SEPARATE red (`validateJob`'s
|
|
348
|
+
// `missing-required`, upstream of export) — this only fires when a value
|
|
349
|
+
// is present and wrong.
|
|
350
|
+
if (isNonEmptyString(stage.sha256)) {
|
|
351
|
+
const actual = hashCloseScriptBytes(source);
|
|
352
|
+
if (actual !== stage.sha256) {
|
|
353
|
+
red('close-sha-mismatch', at, `${basename(scriptPath)}: signed sha256 ${stage.sha256.slice(0, 12)}… does not match the script bytes being packed (${actual.slice(0, 12)}…) — re-sign before exporting`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
381
356
|
});
|
|
382
357
|
|
|
383
358
|
if (reds.length > 0) return fail();
|
|
@@ -391,10 +366,19 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
|
|
|
391
366
|
|
|
392
367
|
/** @type {Record<string, string>} */
|
|
393
368
|
const files = {};
|
|
369
|
+
// F132 — keyed by scriptPath (not name) so the spec-rewrite pass below can
|
|
370
|
+
// look up each relocated stage's REWRITTEN-bytes hash without re-hashing.
|
|
371
|
+
// Both `sha256` here and `hashCloseScriptBytes` (src/close-integrity.js)
|
|
372
|
+
// hash the same way (sha256 of the UTF-8 script text) — this is not a
|
|
373
|
+
// second formula, just this module's own existing helper reused.
|
|
374
|
+
/** @type {Map<string, string>} scriptPath -> sha256 of the RELOCATED bytes */
|
|
375
|
+
const relocatedSha = new Map();
|
|
394
376
|
for (const [scriptPath, name] of byPath) {
|
|
395
377
|
const rewritten = rewriteSrcImports(scripts[scriptPath]);
|
|
396
378
|
writeFileSync(join(closeDir, name), rewritten);
|
|
397
|
-
|
|
379
|
+
const hash = sha256(rewritten);
|
|
380
|
+
files[`close/${name}`] = hash;
|
|
381
|
+
relocatedSha.set(scriptPath, hash);
|
|
398
382
|
}
|
|
399
383
|
|
|
400
384
|
const specOut = { ...spec, close: spec.close.map((/** @type {any} */ stage) => {
|
|
@@ -404,7 +388,17 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
|
|
|
404
388
|
if (!parsed.ok) throw new Error(`unreachable: ${stage.cmd} was validated as a relocatable close cmd`);
|
|
405
389
|
const name = byPath.get(parsed.scriptPath);
|
|
406
390
|
const cmd = ['node', `${BUNDLE_TOKEN}/close/${name}`, ...parsed.rest].join(' ');
|
|
407
|
-
|
|
391
|
+
// F132 (run mtqwmb9l/mtqwydl4): the bundle's manifest hash covers the
|
|
392
|
+
// RELOCATED bytes (the import rewrite above), but the spec's own
|
|
393
|
+
// `close[].sha256` was left carrying the SOURCE spec's signature — two
|
|
394
|
+
// signatures over two different byte strings, so `checkCloseByteSignature`
|
|
395
|
+
// at run start correctly read the bundle as tampered even though nothing
|
|
396
|
+
// was. Re-sign here, over the bytes actually being packed, so the two
|
|
397
|
+
// signatures agree by construction. Only a stage that already carried a
|
|
398
|
+
// signature gets one written back — a stage export never fabricates a
|
|
399
|
+
// signature that was never there (M2 leaves that to the validator).
|
|
400
|
+
const sha256Out = typeof stage.sha256 === 'string' ? (relocatedSha.get(parsed.scriptPath) ?? stage.sha256) : stage.sha256;
|
|
401
|
+
return { ...stage, cmd, sha256: sha256Out };
|
|
408
402
|
}) };
|
|
409
403
|
const specText = `${JSON.stringify(specOut, null, 2)}\n`;
|
|
410
404
|
writeFileSync(join(outDir, 'spec.json'), specText);
|
package/src/cli.js
CHANGED
|
@@ -300,6 +300,14 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
|
|
|
300
300
|
mkdirSync(runsDir, { recursive: true });
|
|
301
301
|
const spineFile = join(runsDir, 'spine.jsonl');
|
|
302
302
|
const emit = makeSpine(spineFile);
|
|
303
|
+
// PRD item 27/M3 Part B — the close's own books directory, inside the run's
|
|
304
|
+
// own directory so the bundle stays one self-contained unit (hamr,
|
|
305
|
+
// 2026-09-07: "export holds as one unit of itself"). `runJob` autosets the
|
|
306
|
+
// close timeout itself (no `closeTimeoutMs` passed — the bundle CLI has
|
|
307
|
+
// always run on the library default/autoset path, never run-u's operator
|
|
308
|
+
// knob).
|
|
309
|
+
const closeDir = join(runsDir, 'close');
|
|
310
|
+
mkdirSync(closeDir, { recursive: true });
|
|
303
311
|
|
|
304
312
|
let outcome;
|
|
305
313
|
try {
|
|
@@ -310,9 +318,13 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
|
|
|
310
318
|
providerFor,
|
|
311
319
|
judgeProvider,
|
|
312
320
|
emit,
|
|
321
|
+
closeDir,
|
|
313
322
|
shellCapUsd: runSpec.budgetUsd,
|
|
314
323
|
readShim: 'cap',
|
|
315
324
|
scout: true,
|
|
325
|
+
// F130/PRD item 27(c) — the bundle CLI has no `--resume` (v1); the
|
|
326
|
+
// honest tail says so instead of naming a flag that does not exist.
|
|
327
|
+
resumable: false,
|
|
316
328
|
});
|
|
317
329
|
} catch (e) {
|
|
318
330
|
err(`runJob crashed: ${/** @type {Error} */ (e).message}`);
|