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.
- package/CHANGELOG.md +132 -0
- package/bareloop.context.md +147 -4
- package/package.json +1 -1
- package/src/authorflow.js +22 -42
- package/src/authorjob.js +5 -8
- package/src/authorrun.js +28 -12
- package/src/panel/authorroutes.js +283 -0
- package/src/panel/authorsession.js +557 -0
- package/src/panel/index.html +1233 -64
- package/src/panel/server.js +487 -40
- package/src/planrun.js +61 -5
- package/src/promptregisters.js +3 -0
- package/src/providers.js +82 -0
- package/src/replay.js +128 -4
- package/src/replayio.js +14 -3
- package/src/run.js +48 -2
- package/src/userrun.js +75 -8
- package/types/authorflow.d.ts +4 -21
- package/types/panel/authorroutes.d.ts +65 -0
- package/types/panel/authorsession.d.ts +61 -0
- package/types/panel/server.d.ts +33 -3
- package/types/providers.d.ts +34 -0
- package/types/replay.d.ts +31 -0
- package/types/replayio.d.ts +12 -1
- package/types/run.d.ts +26 -1
- package/types/userrun.d.ts +17 -0
|
@@ -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
|
+
}
|