bareloop 0.30.0 → 0.32.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/bareloop.context.md +205 -10
  3. package/bin/bareloop.mjs +0 -1
  4. package/package.json +1 -1
  5. package/src/authorflow.js +22 -42
  6. package/src/authorjob.js +5 -8
  7. package/src/authorrun.js +42 -16
  8. package/src/cli.js +12 -6
  9. package/src/config.js +135 -0
  10. package/src/interviewrun.js +10 -2
  11. package/src/keysfile.js +156 -0
  12. package/src/ledger.js +26 -0
  13. package/src/monthly.js +352 -0
  14. package/src/panel/authorroutes.js +328 -0
  15. package/src/panel/authorsession.js +555 -0
  16. package/src/panel/index.html +1247 -50
  17. package/src/panel/server.js +449 -51
  18. package/src/panel/settingsroutes.js +230 -0
  19. package/src/planrun.js +61 -5
  20. package/src/promptregisters.js +3 -0
  21. package/src/providerrows.js +217 -0
  22. package/src/providers.js +84 -0
  23. package/src/replay.js +80 -2
  24. package/src/replayio.js +14 -3
  25. package/src/run.js +48 -2
  26. package/src/runlist.js +13 -0
  27. package/src/userrun.js +134 -13
  28. package/types/authorflow.d.ts +4 -21
  29. package/types/authorrun.d.ts +5 -0
  30. package/types/cli.d.ts +2 -1
  31. package/types/config.d.ts +48 -0
  32. package/types/interviewrun.d.ts +5 -0
  33. package/types/keysfile.d.ts +90 -0
  34. package/types/ledger.d.ts +14 -0
  35. package/types/monthly.d.ts +192 -0
  36. package/types/panel/authorroutes.d.ts +67 -0
  37. package/types/panel/authorsession.d.ts +53 -0
  38. package/types/panel/server.d.ts +42 -5
  39. package/types/panel/settingsroutes.d.ts +14 -0
  40. package/types/providerrows.d.ts +164 -0
  41. package/types/providers.d.ts +34 -0
  42. package/types/replay.d.ts +31 -0
  43. package/types/replayio.d.ts +12 -1
  44. package/types/run.d.ts +26 -1
  45. package/types/runlist.d.ts +1 -0
  46. package/types/userrun.d.ts +22 -0
@@ -0,0 +1,328 @@
1
+ // PANEL-BUILD.md P3 — the WRITE side of the panel: chat/authoring + sign +
2
+ // run-start. Everything in `src/panel/server.js` proper stays read-only
3
+ // (that file's own header comment); this module is the ONE place a POST can
4
+ // reach, and it is deliberately kept separate so the read-only file's own
5
+ // "GET/HEAD only" invariant is never touched by this build.
6
+ //
7
+ // THE HUMAN-CLICK GUARD (hard line, PANEL-BUILD.md §5: "only hamr's own
8
+ // click signs. The chat can't."): every route in this file requires BOTH —
9
+ // (a) the per-server-start token, minted once by {@link mintToken} and
10
+ // templated into `index.html` exactly like the port, sent back on
11
+ // every POST as the `x-bareloop-token` header;
12
+ // (b) an Origin/Host header naming this same server's own bind address.
13
+ // The SIGN route additionally requires the EXACT `specHash` from that
14
+ // session's own `signing.json` (never trusted from the request body alone
15
+ // without that check) — a mismatch, a missing gate, or a session not yet
16
+ // `prepared` all refuse. No other route in this file (start/send/revise/
17
+ // sign-prepare) can reach the sign path — `signRun` is the only function
18
+ // here that ever calls `spawnFn`.
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import { spawn as realSpawn } from 'node:child_process';
22
+ import { openSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { readFileSync } from 'node:fs';
25
+ import { createSession, validateJobCard } from './authorsession.js';
26
+ import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
27
+ import { ConfigError } from '../config.js';
28
+ import { keysForDoor, keysHome } from '../keysfile.js';
29
+ import { keyNameFor, modelChoiceFor, rowsForHome, chatModels, usableKey } from '../providerrows.js';
30
+ import { apiKeyProblem, checkProviderReachable } from '../providers.js';
31
+
32
+ /** @returns {string} a fresh per-process token — never persisted, never logged */
33
+ export function mintToken() {
34
+ return randomBytes(24).toString('hex');
35
+ }
36
+
37
+ /**
38
+ * `true` when a request may reach ANY route in this file — the token header
39
+ * plus an Origin/Host that names this exact bind address. `req.headers`
40
+ * lower-cases header names (node's own `http` behaviour), so this reads
41
+ * lower-case names throughout.
42
+ * @param {import('node:http').IncomingMessage} req
43
+ * @param {{token: string, port: number}} o
44
+ * @returns {{ok: true}|{ok: false, reason: string}}
45
+ */
46
+ export function checkHumanGuard(req, { token, port }) {
47
+ const got = req.headers['x-bareloop-token'];
48
+ if (got !== token) return { ok: false, reason: 'missing or wrong token' };
49
+ const want = `127.0.0.1:${port}`;
50
+ const origin = req.headers.origin;
51
+ const host = req.headers.host;
52
+ if (typeof origin === 'string' && origin.length > 0) {
53
+ if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
54
+ } else if (host !== want) {
55
+ return { ok: false, reason: 'wrong Host (and no Origin header)' };
56
+ }
57
+ return { ok: true };
58
+ }
59
+
60
+ /**
61
+ * The one-at-a-time authoring session store (build spec: "one authoring
62
+ * session at a time — a second Start is refused while one is live"). A
63
+ * session is "live" from creation until its phase settles into a terminal
64
+ * one; a terminal session is kept (so its chat/hash stay readable) but no
65
+ * longer counts against the one-at-a-time rule.
66
+ */
67
+ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
68
+
69
+ /**
70
+ * Builds this file's own request handler, holding its session store in a
71
+ * closure (one per `createPanelServer` call, exactly like `handleRequest`'s
72
+ * own module holds none — this file owns the state `src/panel/server.js`
73
+ * deliberately has none of).
74
+ * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
75
+ * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
76
+ * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
77
+ */
78
+ export function createAuthorRoutes(opts) {
79
+ // the RAW env with the keys file re-merged on every use, so an edited file takes effect
80
+ // without a restart (never a start-time snapshot). An injected env with no `home` skips the file.
81
+ const envNow = () => keysForDoor({ env: opts.env, keysHome: opts.home }).env;
82
+ const spawnFn = opts.spawnFn ?? realSpawn;
83
+ const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
84
+ // TEST SEAM ONLY: a test injects a fake `fetch`-shaped function so the
85
+ // model-readiness route's own network probe (item 7 below) never makes a
86
+ // real request — production always uses the real global `fetch`.
87
+ const fetchImpl = opts.fetchImpl ?? fetch;
88
+ /** @type {Map<string, ReturnType<typeof createSession>>} */
89
+ const sessions = new Map();
90
+
91
+ const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
92
+
93
+ /**
94
+ * @param {import('node:http').IncomingMessage} req
95
+ * @param {import('node:http').ServerResponse} res
96
+ * @param {string} pathname
97
+ * @param {any} body already-parsed JSON body (or `null`)
98
+ * @returns {boolean} true if this module handled the request
99
+ */
100
+ function handle(req, res, pathname, body) {
101
+ if (!pathname.startsWith('/api/author')) return false;
102
+
103
+ const send = (code, obj) => {
104
+ const text = JSON.stringify(obj);
105
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
106
+ res.end(text);
107
+ };
108
+
109
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
110
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
111
+
112
+ // ── build item 7: model readiness, BEFORE drafting, $0/no tokens ──────
113
+ // ONE server-side function (`checkProviderReachable`, src/providers.js)
114
+ // the future Settings providers table (PANEL-BUILD.md P4) reuses as-is —
115
+ // this route is the ONLY caller today. Never returns key material: the
116
+ // page sees the key's NAME (`envKey`) and a status word, never the value.
117
+ if (pathname === '/api/author/model-check') {
118
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
119
+ const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
120
+ const modelId = q.get('model') ?? '';
121
+ const rows = rowsForHome(keysHome(opts.home));
122
+ const choice = modelChoiceFor(rows, modelId);
123
+ if (!choice) { send(400, { ok: false, error: `unknown model "${modelId}"` }); return true; }
124
+ // the key of the Settings row this Name belongs to
125
+ const keyName = keyNameFor(choice.provider, choice.baseUrl, rows, choice.name).name;
126
+ const raw = envNow()[keyName];
127
+ const problem = raw ? apiKeyProblem(raw) : null;
128
+ const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
129
+ if (keyStatus !== 'found') {
130
+ send(200, {
131
+ ok: true, envKey: keyName, keyStatus, keyProblem: problem,
132
+ reachability: { checked: false, reachable: null, modelListed: null, status: null, note: 'no usable key to check reachability with' },
133
+ });
134
+ return true;
135
+ }
136
+ // GET ONLY, no completion request, never spends a token (see
137
+ // `checkProviderReachable`'s own doc) — a short timeout so a flaky
138
+ // endpoint never hangs the card.
139
+ checkProviderReachable({
140
+ providerName: choice.provider, apiKey: /** @type {string} */ (usableKey(raw)), model: choice.name,
141
+ baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
142
+ }).then((r) => {
143
+ send(200, {
144
+ ok: true,
145
+ envKey: keyName,
146
+ keyStatus,
147
+ keyProblem: null,
148
+ reachability: {
149
+ checked: true, reachable: r.reachable, modelListed: r.modelListed, status: r.status, note: r.note,
150
+ },
151
+ });
152
+ });
153
+ return true;
154
+ }
155
+
156
+ // ── P4a: the monthly-limit note under the cap field. READ-ONLY, $0 — the same
157
+ // library check (`checkMonthlyRoom`, src/monthly.js) the run-start seam and the
158
+ // Sign refusal use, so the note the person sees is never a second opinion. The
159
+ // page is never the arbiter: Sign & run re-checks server-side regardless.
160
+ // ── P4b: Chat's Model menu IS the Settings rows (a blank Name is not offered). ──
161
+ if (pathname === '/api/author/models') {
162
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
163
+ send(200, { ok: true, models: chatModels(rowsForHome(keysHome(opts.home))) });
164
+ return true;
165
+ }
166
+
167
+ if (pathname === '/api/author/monthly-check') {
168
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
169
+ const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
170
+ const cap = Number(q.get('cap'));
171
+ if (!Number.isFinite(cap) || cap <= 0) { send(200, { ok: true, refusal: null }); return true; }
172
+ try {
173
+ const room = checkMonthlyRoom({ capUsd: cap, home: opts.home });
174
+ send(200, { ok: true, refusal: monthlyRefusalText(room), leftUsd: room.leftUsd, atLeast: room.atLeast });
175
+ } catch (e) {
176
+ if (!(e instanceof ConfigError)) throw e;
177
+ send(200, { ok: true, refusal: null, configProblem: e.message });
178
+ }
179
+ return true;
180
+ }
181
+
182
+ if (pathname === '/api/author/start') {
183
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
184
+ if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
185
+ const card = body ?? {};
186
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
187
+ if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
188
+ const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
189
+ sessions.set(session.id, session);
190
+ send(200, { ok: true, sessionId: session.id, state: session.state });
191
+ return true;
192
+ }
193
+
194
+ const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps))?$/.exec(pathname);
195
+ if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
196
+ const session = sessions.get(m[1]);
197
+ if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
198
+ const sub = m[3] ?? null;
199
+
200
+ if (sub === null) {
201
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
202
+ send(200, { ok: true, state: session.state });
203
+ return true;
204
+ }
205
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
206
+
207
+ if (sub === 'send') {
208
+ const r = session.send(String(body?.text ?? ''));
209
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
210
+ return true;
211
+ }
212
+ if (sub === 'revise') {
213
+ session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
214
+ return true;
215
+ }
216
+ if (sub === 'check-deps') {
217
+ const r = session.checkDeps();
218
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
219
+ return true;
220
+ }
221
+ if (sub === 'sign-prepare') {
222
+ const r = session.signPrepare();
223
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
224
+ return true;
225
+ }
226
+ if (sub === 'sign') {
227
+ const claimedHash = String(body?.specHash ?? '');
228
+ const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
229
+ send(r.ok ? 200 : 400, r);
230
+ return true;
231
+ }
232
+ send(404, { ok: false, error: 'not found' });
233
+ return true;
234
+ }
235
+
236
+ return { handle, sessions };
237
+ }
238
+
239
+ /**
240
+ * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
241
+ * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
242
+ * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
243
+ * free). This is the server-side (Node, chat-text) sibling of
244
+ * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
245
+ * code (one runs in the browser as inlined script, the other in Node), the
246
+ * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
247
+ * already carry, so both apply the identical rule rather than drift.
248
+ * @param {number} n a non-negative dollar amount
249
+ * @returns {string}
250
+ */
251
+ function panelMoney2(n) {
252
+ if (n > 0 && n < 0.01) return '<$0.01';
253
+ // two-step rounding (matching index.html's own panelMoney): clean to
254
+ // 6-decimal precision first (the same precision src/text.js's tallyCalls
255
+ // sums to) before rounding to cents, so a summed float landing just under
256
+ // an exact cent boundary is never mis-rounded.
257
+ const clean = Math.round(n * 1e6) / 1e6;
258
+ const cents = Math.round(clean * 100 + 1e-6);
259
+ return `$${(cents / 100).toFixed(2)}`;
260
+ }
261
+
262
+ /**
263
+ * THE ONLY function in this file (or anywhere in the panel) that spawns a
264
+ * run. Refuses unless the session is `prepared` AND the hash the request
265
+ * carried matches the session's OWN `signing.json` `specHash` exactly — the
266
+ * click carries the hash (per the mockup's own "the person never has to copy
267
+ * it to sign"), and this is where it is checked, not trusted.
268
+ * @param {ReturnType<typeof createSession>} session
269
+ * @param {string} claimedHash
270
+ * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
271
+ * @returns {{ok: boolean, error?: string, job?: string}}
272
+ */
273
+ export function signRun(session, claimedHash, o) {
274
+ if (session.state.phase !== 'prepared') return { ok: false, error: `session is not prepared (phase=${session.state.phase}) — nothing to sign` };
275
+ if (!session.state.specHash || claimedHash !== session.state.specHash) return { ok: false, error: 'spec hash mismatch — refusing to sign' };
276
+ if (!session.state.resolvedSpecPath) return { ok: false, error: 'no resolved spec on disk — refusing to sign' };
277
+ // P4a — the monthly $ limit REFUSES here too, server-side, before anything is spawned
278
+ // (the page's note is a courtesy; this is the check). Same library call, same text as
279
+ // the run-start seam in src/userrun.js. The session stays `prepared`, so the person can
280
+ // lower nothing (the cap is signed in) but may raise the limit in Settings and sign again.
281
+ try {
282
+ const spec = JSON.parse(readFileSync(session.state.resolvedSpecPath, 'utf8'));
283
+ const refusal = monthlyRefusalText(checkMonthlyRoom({ capUsd: Number(spec.budgetUsd), home: o.home }));
284
+ if (refusal !== null) return { ok: false, error: refusal };
285
+ } catch (e) {
286
+ if (e instanceof ConfigError) return { ok: false, error: `${e.message} — refusing rather than guess the monthly limit` };
287
+ return { ok: false, error: 'could not read the signed spec to check the monthly limit — refusing to sign' };
288
+ }
289
+ const logFile = join(session.state.outDir, 'run.log');
290
+ // array argv, never a shell string — `--approve <hash>` is a literal
291
+ // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
292
+ // is no shell to mis-quote a path or a hash through.
293
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run") — this
294
+ // session's own drafting spend (the known floor `authorsession.js`'s
295
+ // `onCall` tracks, off the SAME `metered` list the chat's cost readout
296
+ // already used) rides along so the run's own enforced ceiling is
297
+ // `Cap $ − drafting spent`, computed in the ONE place (`src/run.js`'s
298
+ // `remainingUsd`) that arithmetic lives. Omitted when there is nothing to
299
+ // report (a session that spent $0 drafting, e.g. every gate 1-3-only
300
+ // path) — `run-u`'s own flag default (0) is identical, so this is never a
301
+ // silent difference.
302
+ const draftSpentUsd = typeof session.state.draftSpentUsd === 'number' && session.state.draftSpentUsd > 0
303
+ ? session.state.draftSpentUsd : null;
304
+ // hamr's ruling 2026-09-28 (2nd addendum) — a NEW field beside draftSpentUsd
305
+ // (never a value inside it): whether this session's own known floor was
306
+ // EXACT. Meaningless without a drafting spend, so only ever passed
307
+ // alongside --draft-spent-usd, matching run-u's own guard.
308
+ const draftIncomplete = draftSpentUsd !== null && session.state.draftSpendComplete === false;
309
+ const args = [
310
+ 'systemd-inhibit', '--why=bareloop panel run',
311
+ process.execPath, o.bareloopBin,
312
+ 'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
313
+ ...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
314
+ ];
315
+ /** @type {number|null} */
316
+ let logFd = null;
317
+ try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
318
+ const child = o.spawnFn('setsid', args, {
319
+ detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
320
+ });
321
+ if (typeof child?.unref === 'function') child.unref();
322
+ session.state.phase = 'signed';
323
+ const draftLine = draftSpentUsd !== null
324
+ ? ` — drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
325
+ : '';
326
+ session.state.messages.push({ role: 'system', text: `signed — spec hash ${session.state.specHash}${draftLine} — run starting detached, own log at ${logFile}` });
327
+ return { ok: true, job: session.state.resolvedSpecPath };
328
+ }