bareloop 0.31.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.
@@ -21,8 +21,13 @@ import { randomBytes } from 'node:crypto';
21
21
  import { spawn as realSpawn } from 'node:child_process';
22
22
  import { openSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
- import { createSession, validateJobCard, MODEL_OPTIONS } from './authorsession.js';
25
- import { resolveProvider, apiKeyProblem, checkProviderReachable } from '../providers.js';
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';
26
31
 
27
32
  /** @returns {string} a fresh per-process token — never persisted, never logged */
28
33
  export function mintToken() {
@@ -68,10 +73,12 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
68
73
  * deliberately has none of).
69
74
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
70
75
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
71
- * jobsDir?: string, fetchImpl?: typeof fetch }} opts
76
+ * jobsDir?: string, fetchImpl?: typeof fetch, home?: string }} opts
72
77
  */
73
78
  export function createAuthorRoutes(opts) {
74
- const env = opts.env ?? process.env;
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;
75
82
  const spawnFn = opts.spawnFn ?? realSpawn;
76
83
  const bareloopBin = opts.bareloopBin ?? new URL('../../bin/bareloop.mjs', import.meta.url).pathname;
77
84
  // TEST SEAM ONLY: a test injects a fake `fetch`-shaped function so the
@@ -111,17 +118,17 @@ export function createAuthorRoutes(opts) {
111
118
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
112
119
  const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
113
120
  const modelId = q.get('model') ?? '';
114
- const choice = MODEL_OPTIONS[modelId];
121
+ const rows = rowsForHome(keysHome(opts.home));
122
+ const choice = modelChoiceFor(rows, modelId);
115
123
  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];
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];
120
127
  const problem = raw ? apiKeyProblem(raw) : null;
121
128
  const keyStatus = !raw ? 'missing' : (problem ? 'bad-shape' : 'found');
122
129
  if (keyStatus !== 'found') {
123
130
  send(200, {
124
- ok: true, envKey: providerEntry.envKey, keyStatus, keyProblem: problem,
131
+ ok: true, envKey: keyName, keyStatus, keyProblem: problem,
125
132
  reachability: { checked: false, reachable: null, modelListed: null, status: null, note: 'no usable key to check reachability with' },
126
133
  });
127
134
  return true;
@@ -130,12 +137,12 @@ export function createAuthorRoutes(opts) {
130
137
  // `checkProviderReachable`'s own doc) — a short timeout so a flaky
131
138
  // endpoint never hangs the card.
132
139
  checkProviderReachable({
133
- providerName: choice.provider, apiKey: /** @type {string} */ (raw), model: providerEntry.tiers.sonnet,
140
+ providerName: choice.provider, apiKey: /** @type {string} */ (usableKey(raw)), model: choice.name,
134
141
  baseUrl: choice.baseUrl, fetchImpl, timeoutMs: 4000,
135
142
  }).then((r) => {
136
143
  send(200, {
137
144
  ok: true,
138
- envKey: providerEntry.envKey,
145
+ envKey: keyName,
139
146
  keyStatus,
140
147
  keyProblem: null,
141
148
  reachability: {
@@ -146,13 +153,39 @@ export function createAuthorRoutes(opts) {
146
153
  return true;
147
154
  }
148
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
+
149
182
  if (pathname === '/api/author/start') {
150
183
  if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
151
184
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
152
185
  const card = body ?? {};
153
- const v = validateJobCard(card);
186
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows: rowsForHome(keysHome(opts.home)) });
154
187
  if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
155
- const session = createSession(card, { env, sessionsRoot: opts.sessionsRoot });
188
+ const session = createSession(card, { env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}) });
156
189
  sessions.set(session.id, session);
157
190
  send(200, { ok: true, sessionId: session.id, state: session.state });
158
191
  return true;
@@ -192,7 +225,7 @@ export function createAuthorRoutes(opts) {
192
225
  }
193
226
  if (sub === 'sign') {
194
227
  const claimedHash = String(body?.specHash ?? '');
195
- const r = signRun(session, claimedHash, { env, spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot });
228
+ const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
196
229
  send(r.ok ? 200 : 400, r);
197
230
  return true;
198
231
  }
@@ -234,13 +267,25 @@ function panelMoney2(n) {
234
267
  * it to sign"), and this is where it is checked, not trusted.
235
268
  * @param {ReturnType<typeof createSession>} session
236
269
  * @param {string} claimedHash
237
- * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string}} o
270
+ * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
238
271
  * @returns {{ok: boolean, error?: string, job?: string}}
239
272
  */
240
273
  export function signRun(session, claimedHash, o) {
241
274
  if (session.state.phase !== 'prepared') return { ok: false, error: `session is not prepared (phase=${session.state.phase}) — nothing to sign` };
242
275
  if (!session.state.specHash || claimedHash !== session.state.specHash) return { ok: false, error: 'spec hash mismatch — refusing to sign' };
243
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
+ }
244
289
  const logFile = join(session.state.outDir, 'run.log');
245
290
  // array argv, never a shell string — `--approve <hash>` is a literal
246
291
  // element, checkable byte-for-byte by a test stubbing `spawnFn`, and there
@@ -20,10 +20,11 @@
20
20
  // `src/panel/server.js`'s POST routes). Nothing here changes what a step
21
21
  // means or what an answer does — only where the answer comes from.
22
22
 
23
+ import { applyConfiguredKey, keyNameFor, modelChoiceFor, rowsForHome } from '../providerrows.js';
23
24
  import {
24
25
  mkdirSync, existsSync, writeFileSync, readdirSync,
25
26
  } from 'node:fs';
26
- import { join, resolve } from 'node:path';
27
+ import { join, resolve, dirname } from 'node:path';
27
28
  import {
28
29
  prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
29
30
  } from '../source.js';
@@ -46,20 +47,6 @@ import { closeJudges } from '../kinds.js';
46
47
  import { redactSecrets } from '../validate.js';
47
48
  import { tallyCalls } from '../text.js';
48
49
 
49
- /**
50
- * The job card's "Model" select — a fixed, small, source-grounded mapping
51
- * from a display id to the `{provider, baseUrl?}` pair `src/interviewrun.js`
52
- * itself documents (that script's own `--provider openai-api --base-url
53
- * https://api.deepseek.com/v1` usage line for DeepSeek): the panel builds no
54
- * provider table of its own, this is a UI convenience over the SAME two
55
- * provider identities the CLI already resolves through `resolveProvider`.
56
- * @type {Readonly<Record<string, {provider: string, baseUrl?: string}>>}
57
- */
58
- export const MODEL_OPTIONS = Object.freeze({
59
- 'claude-sonnet-5': Object.freeze({ provider: 'anthropic-api' }),
60
- 'deepseek-flash': Object.freeze({ provider: 'openai-api', baseUrl: 'https://api.deepseek.com/v1' }),
61
- });
62
-
63
50
  /**
64
51
  * Plain-language labels for the library's own `onPhase(name, …)` calls
65
52
  * (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
@@ -126,17 +113,18 @@ export function jobNameTaken(name, opts = {}) {
126
113
  * creates a session or spends anything — never inside the async pipeline,
127
114
  * so a bad card refuses synchronously, in the same HTTP response.
128
115
  * @param {any} card
116
+ * @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
129
117
  * @returns {{ok: true}|{ok: false, error: string}}
130
118
  */
131
- export function validateJobCard(card) {
119
+ export function validateJobCard(card, opts = {}) {
132
120
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
133
121
  const { jobName, checkType, model, goal, source, destination, success, guardrails, capUsd } = card;
134
122
  if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
135
- if (jobNameTaken(jobName, card.jobsDirOverride ? { jobsDir: card.jobsDirOverride } : {})) {
123
+ if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
136
124
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
137
125
  }
138
126
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
139
- if (!MODEL_OPTIONS[model]) return { ok: false, error: `Model must be one of: ${Object.keys(MODEL_OPTIONS).join(', ')}` };
127
+ if (!modelChoiceFor(opts.rows ?? [], model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
140
128
  for (const [field, label] of [[goal, 'Goal'], [source, 'Source'], [destination, 'Destination'], [success, 'Success'], [guardrails, 'Guardrails']]) {
141
129
  if (typeof field !== 'string' || field.trim() === '') return { ok: false, error: `${label} is required` };
142
130
  }
@@ -155,7 +143,7 @@ export function validateJobCard(card) {
155
143
  * is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
156
144
  * `signPrepare()` are the only ways in.
157
145
  * @param {any} card the validated job card (see {@link validateJobCard})
158
- * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, timeoutMs?: number,
146
+ * @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
159
147
  * scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
160
148
  * prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
161
149
  * just above where each is read, below.
@@ -164,6 +152,8 @@ export function validateJobCard(card) {
164
152
  export function createSession(card, deps = {}) {
165
153
  const env = deps.env ?? process.env;
166
154
  const sessionsRoot = deps.sessionsRoot ?? join(process.env.HOME ?? '/tmp', '.config', 'bareloop', 'panel-sessions');
155
+ // config.json lives beside the sessions dir (~/.config/bareloop) unless a home is injected
156
+ const configHome = deps.home ?? dirname(sessionsRoot);
167
157
  const timeoutMs = deps.timeoutMs ?? 300_000;
168
158
  // TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
169
159
  // override the declaration composer and/or `prepareSigning` itself so a
@@ -309,13 +299,19 @@ export function createSession(card, deps = {}) {
309
299
 
310
300
  async function run() {
311
301
  // ── keys, at $0, before anything is prepared ────────────────────────
312
- const modelChoice = MODEL_OPTIONS[card.model];
302
+ const keyRows = rowsForHome(configHome);
303
+ const modelChoice = modelChoiceFor(keyRows, card.model);
304
+ if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
313
305
  let providerEntry;
314
306
  try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
315
- const apiKey = env[providerEntry.envKey];
316
- if (!apiKey) { refuse(`${providerEntry.envKey} not set. Stopped — nothing spent.`); return; }
307
+ // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
308
+ const keyCfg = keyRows;
309
+ const draftEnv = applyConfiguredKey(env, modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name);
310
+ const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
311
+ const apiKey = draftEnv[providerEntry.envKey];
312
+ if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
317
313
  const keyProblem = apiKeyProblem(apiKey);
318
- if (keyProblem) { refuse(`${providerEntry.envKey} ${keyProblem}. Stopped — nothing spent.`); return; }
314
+ if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
319
315
 
320
316
  const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
321
317
  const into = join(outDir, 'source-seed');
@@ -389,6 +385,8 @@ export function createSession(card, deps = {}) {
389
385
  description: `${card.jobName} — authored through the bareloop panel (${verdictType}, ${LANG})`,
390
386
  provider: modelChoice.provider,
391
387
  ...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
388
+ // the Name is the model id: signed into the spec only when it is not the provider's own default
389
+ ...(modelChoice.name !== providerEntry.tiers.sonnet ? { model: modelChoice.name } : {}),
392
390
  cadence: { unit: 'day', every: 1 },
393
391
  budgetUsd: card.capUsd,
394
392
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
@@ -414,7 +412,7 @@ export function createSession(card, deps = {}) {
414
412
  const draftJudge = (() => {
415
413
  try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
416
414
  })();
417
- const MODEL = providerEntry.tiers.sonnet;
415
+ const MODEL = modelChoice.name;
418
416
  const { provider } = buildRunnerProviders({
419
417
  providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
420
418
  judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
@@ -473,7 +471,7 @@ export function createSession(card, deps = {}) {
473
471
  const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
474
472
  let judgeProvider = null;
475
473
  if (judge) {
476
- const judgeKeyValue = env.JUDGE_API_KEY ?? env[resolveProvider(judge.provider).envKey];
474
+ const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
477
475
  const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
478
476
  if (!judgeKeyValue || judgeKeyProblem) {
479
477
  refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);