bareloop 0.29.0 → 0.31.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.
@@ -0,0 +1,283 @@
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 { createSession, validateJobCard, MODEL_OPTIONS } from './authorsession.js';
25
+ import { resolveProvider, apiKeyProblem, checkProviderReachable } from '../providers.js';
26
+
27
+ /** @returns {string} a fresh per-process token — never persisted, never logged */
28
+ export function mintToken() {
29
+ return randomBytes(24).toString('hex');
30
+ }
31
+
32
+ /**
33
+ * `true` when a request may reach ANY route in this file — the token header
34
+ * plus an Origin/Host that names this exact bind address. `req.headers`
35
+ * lower-cases header names (node's own `http` behaviour), so this reads
36
+ * lower-case names throughout.
37
+ * @param {import('node:http').IncomingMessage} req
38
+ * @param {{token: string, port: number}} o
39
+ * @returns {{ok: true}|{ok: false, reason: string}}
40
+ */
41
+ export function checkHumanGuard(req, { token, port }) {
42
+ const got = req.headers['x-bareloop-token'];
43
+ if (got !== token) return { ok: false, reason: 'missing or wrong token' };
44
+ const want = `127.0.0.1:${port}`;
45
+ const origin = req.headers.origin;
46
+ const host = req.headers.host;
47
+ if (typeof origin === 'string' && origin.length > 0) {
48
+ if (origin !== `http://${want}` && origin !== `https://${want}`) return { ok: false, reason: 'wrong Origin' };
49
+ } else if (host !== want) {
50
+ return { ok: false, reason: 'wrong Host (and no Origin header)' };
51
+ }
52
+ return { ok: true };
53
+ }
54
+
55
+ /**
56
+ * The one-at-a-time authoring session store (build spec: "one authoring
57
+ * session at a time — a second Start is refused while one is live"). A
58
+ * session is "live" from creation until its phase settles into a terminal
59
+ * one; a terminal session is kept (so its chat/hash stay readable) but no
60
+ * longer counts against the one-at-a-time rule.
61
+ */
62
+ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
63
+
64
+ /**
65
+ * Builds this file's own request handler, holding its session store in a
66
+ * closure (one per `createPanelServer` call, exactly like `handleRequest`'s
67
+ * own module holds none — this file owns the state `src/panel/server.js`
68
+ * deliberately has none of).
69
+ * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
70
+ * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
71
+ * jobsDir?: string, fetchImpl?: typeof fetch }} opts
72
+ */
73
+ export function createAuthorRoutes(opts) {
74
+ const env = opts.env ?? process.env;
75
+ const spawnFn = opts.spawnFn ?? realSpawn;
76
+ const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
77
+ // TEST SEAM ONLY: a test injects a fake `fetch`-shaped function so the
78
+ // model-readiness route's own network probe (item 7 below) never makes a
79
+ // real request — production always uses the real global `fetch`.
80
+ const fetchImpl = opts.fetchImpl ?? fetch;
81
+ /** @type {Map<string, ReturnType<typeof createSession>>} */
82
+ const sessions = new Map();
83
+
84
+ const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
85
+
86
+ /**
87
+ * @param {import('node:http').IncomingMessage} req
88
+ * @param {import('node:http').ServerResponse} res
89
+ * @param {string} pathname
90
+ * @param {any} body already-parsed JSON body (or `null`)
91
+ * @returns {boolean} true if this module handled the request
92
+ */
93
+ function handle(req, res, pathname, body) {
94
+ if (!pathname.startsWith('/api/author')) return false;
95
+
96
+ const send = (code, obj) => {
97
+ const text = JSON.stringify(obj);
98
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
99
+ res.end(text);
100
+ };
101
+
102
+ const guard = checkHumanGuard(req, { token: opts.token, port: opts.port });
103
+ if (!guard.ok) { send(403, { ok: false, error: `refused — ${guard.reason}` }); return true; }
104
+
105
+ // ── build item 7: model readiness, BEFORE drafting, $0/no tokens ──────
106
+ // ONE server-side function (`checkProviderReachable`, src/providers.js)
107
+ // the future Settings providers table (PANEL-BUILD.md P4) reuses as-is —
108
+ // this route is the ONLY caller today. Never returns key material: the
109
+ // page sees the key's NAME (`envKey`) and a status word, never the value.
110
+ if (pathname === '/api/author/model-check') {
111
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
112
+ const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
113
+ const modelId = q.get('model') ?? '';
114
+ const choice = MODEL_OPTIONS[modelId];
115
+ if (!choice) { send(400, { ok: false, error: `unknown model "${modelId}"` }); return true; }
116
+ /** @type {any} */
117
+ let providerEntry;
118
+ try { providerEntry = resolveProvider(choice.provider); } catch (e) { send(400, { ok: false, error: /** @type {Error} */ (e).message }); return true; }
119
+ const raw = env[providerEntry.envKey];
120
+ const problem = raw ? apiKeyProblem(raw) : null;
121
+ const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
122
+ if (keyStatus !== 'found') {
123
+ send(200, {
124
+ ok: true, envKey: providerEntry.envKey, keyStatus, keyProblem: problem,
125
+ reachability: { checked: false, reachable: null, modelListed: null, status: null, note: 'no usable key to check reachability with' },
126
+ });
127
+ return true;
128
+ }
129
+ // GET ONLY, no completion request, never spends a token (see
130
+ // `checkProviderReachable`'s own doc) — a short timeout so a flaky
131
+ // endpoint never hangs the card.
132
+ checkProviderReachable({
133
+ providerName: choice.provider, apiKey: /** @type {string} */ (raw), model: providerEntry.tiers.sonnet,
134
+ baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
135
+ }).then((r) => {
136
+ send(200, {
137
+ ok: true,
138
+ envKey: providerEntry.envKey,
139
+ keyStatus,
140
+ keyProblem: null,
141
+ reachability: {
142
+ checked: true, reachable: r.reachable, modelListed: r.modelListed, status: r.status, note: r.note,
143
+ },
144
+ });
145
+ });
146
+ return true;
147
+ }
148
+
149
+ if (pathname === '/api/author/start') {
150
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
151
+ if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
152
+ const card = body ?? {};
153
+ const v = validateJobCard(card);
154
+ if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
155
+ const session = createSession(card, { env, sessionsRoot: opts.sessionsRoot });
156
+ sessions.set(session.id, session);
157
+ send(200, { ok: true, sessionId: session.id, state: session.state });
158
+ return true;
159
+ }
160
+
161
+ const m = /^\/api\/author\/([A-Za-z0-9]+)(\/(send|revise|sign-prepare|sign|check-deps))?$/.exec(pathname);
162
+ if (!m) { send(404, { ok: false, error: 'not found' }); return true; }
163
+ const session = sessions.get(m[1]);
164
+ if (!session) { send(404, { ok: false, error: 'no such session' }); return true; }
165
+ const sub = m[3] ?? null;
166
+
167
+ if (sub === null) {
168
+ if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
169
+ send(200, { ok: true, state: session.state });
170
+ return true;
171
+ }
172
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
173
+
174
+ if (sub === 'send') {
175
+ const r = session.send(String(body?.text ?? ''));
176
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
177
+ return true;
178
+ }
179
+ if (sub === 'revise') {
180
+ session.revise(String(body?.text ?? '')).then((r) => send(r.ok ? 200 : 400, { ...r, state: session.state }));
181
+ return true;
182
+ }
183
+ if (sub === 'check-deps') {
184
+ const r = session.checkDeps();
185
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
186
+ return true;
187
+ }
188
+ if (sub === 'sign-prepare') {
189
+ const r = session.signPrepare();
190
+ send(r.ok ? 200 : 400, { ...r, state: session.state });
191
+ return true;
192
+ }
193
+ if (sub === 'sign') {
194
+ const claimedHash = String(body?.specHash ?? '');
195
+ const r = signRun(session, claimedHash, { env, spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot });
196
+ send(r.ok ? 200 : 400, r);
197
+ return true;
198
+ }
199
+ send(404, { ok: false, error: 'not found' });
200
+ return true;
201
+ }
202
+
203
+ return { handle, sessions };
204
+ }
205
+
206
+ /**
207
+ * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
208
+ * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
209
+ * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
210
+ * free). This is the server-side (Node, chat-text) sibling of
211
+ * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
212
+ * code (one runs in the browser as inlined script, the other in Node), the
213
+ * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
214
+ * already carry, so both apply the identical rule rather than drift.
215
+ * @param {number} n a non-negative dollar amount
216
+ * @returns {string}
217
+ */
218
+ function panelMoney2(n) {
219
+ if (n > 0 && n < 0.01) return '<$0.01';
220
+ // two-step rounding (matching index.html's own panelMoney): clean to
221
+ // 6-decimal precision first (the same precision src/text.js's tallyCalls
222
+ // sums to) before rounding to cents, so a summed float landing just under
223
+ // an exact cent boundary is never mis-rounded.
224
+ const clean = Math.round(n * 1e6) / 1e6;
225
+ const cents = Math.round(clean * 100 + 1e-6);
226
+ return `$${(cents / 100).toFixed(2)}`;
227
+ }
228
+
229
+ /**
230
+ * THE ONLY function in this file (or anywhere in the panel) that spawns a
231
+ * run. Refuses unless the session is `prepared` AND the hash the request
232
+ * carried matches the session's OWN `signing.json` `specHash` exactly — the
233
+ * click carries the hash (per the mockup's own "the person never has to copy
234
+ * it to sign"), and this is where it is checked, not trusted.
235
+ * @param {ReturnType<typeof createSession>} session
236
+ * @param {string} claimedHash
237
+ * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string}} o
238
+ * @returns {{ok: boolean, error?: string, job?: string}}
239
+ */
240
+ export function signRun(session, claimedHash, o) {
241
+ if (session.state.phase !== 'prepared') return { ok: false, error: `session is not prepared (phase=${session.state.phase}) — nothing to sign` };
242
+ if (!session.state.specHash || claimedHash !== session.state.specHash) return { ok: false, error: 'spec hash mismatch — refusing to sign' };
243
+ if (!session.state.resolvedSpecPath) return { ok: false, error: 'no resolved spec on disk — refusing to sign' };
244
+ const logFile = join(session.state.outDir, 'run.log');
245
+ // array argv, never a shell string — `--approve <hash>` is a literal
246
+ // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
247
+ // is no shell to mis-quote a path or a hash through.
248
+ // hamr's ruling 2026-09-28 ("one cap covers drafting + run") — this
249
+ // session's own drafting spend (the known floor `authorsession.js`'s
250
+ // `onCall` tracks, off the SAME `metered` list the chat's cost readout
251
+ // already used) rides along so the run's own enforced ceiling is
252
+ // `Cap $ − drafting spent`, computed in the ONE place (`src/run.js`'s
253
+ // `remainingUsd`) that arithmetic lives. Omitted when there is nothing to
254
+ // report (a session that spent $0 drafting, e.g. every gate 1-3-only
255
+ // path) — `run-u`'s own flag default (0) is identical, so this is never a
256
+ // silent difference.
257
+ const draftSpentUsd = typeof session.state.draftSpentUsd === 'number' && session.state.draftSpentUsd > 0
258
+ ? session.state.draftSpentUsd : null;
259
+ // hamr's ruling 2026-09-28 (2nd addendum) — a NEW field beside draftSpentUsd
260
+ // (never a value inside it): whether this session's own known floor was
261
+ // EXACT. Meaningless without a drafting spend, so only ever passed
262
+ // alongside --draft-spent-usd, matching run-u's own guard.
263
+ const draftIncomplete = draftSpentUsd !== null && session.state.draftSpendComplete === false;
264
+ const args = [
265
+ 'systemd-inhibit', '--why=bareloop panel run',
266
+ process.execPath, o.bareloopBin,
267
+ 'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
268
+ ...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
269
+ ];
270
+ /** @type {number|null} */
271
+ let logFd = null;
272
+ try { logFd = openSync(logFile, 'a'); } catch { logFd = null; }
273
+ const child = o.spawnFn('setsid', args, {
274
+ detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
275
+ });
276
+ if (typeof child?.unref === 'function') child.unref();
277
+ session.state.phase = 'signed';
278
+ const draftLine = draftSpentUsd !== null
279
+ ? ` — drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
280
+ : '';
281
+ session.state.messages.push({ role: 'system', text: `signed — spec hash ${session.state.specHash}${draftLine} — run starting detached, own log at ${logFile}` });
282
+ return { ok: true, job: session.state.resolvedSpecPath };
283
+ }