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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,78 @@ 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.32.0] — 2026-09-29
9
+
10
+ ### Added
11
+
12
+ - **Panel Settings (P4a)** — `~/.config/bareloop/.env` keys-file loader (shell env wins; keys
13
+ never reach the page), `config.json` (monthly $ limit, chosen key variables, Anthropic balance
14
+ note), a monthly $ limit that refuses a run start with `Max $X (monthly limit)` at both
15
+ `run-u` and the panel Sign & run, and a Settings view with a Money & limits tab and a Providers
16
+ tab (read, $0 Test, key-name dropdown). Add/edit/remove providers is P4b. There is no monthly
17
+ time limit. **The monthly limit is checked by `run-u` and the panel only — `bareloop run
18
+ <bundle>` does not check it.** An unreadable `config.json` refuses a run start; a month whose
19
+ spend is incomplete reads "at least".
20
+ - **Money & limits tab** — total and this-month spend and tokens as two header tiles ($ and
21
+ tokens, month first), a monthly $ limit that saves on change, and a per-provider breakdown
22
+ table ($, wall minutes, tokens; this month and to date). Wall time is job-start to job-end
23
+ when the run ended, otherwise an "at least" floor; unknown is shown as unknown, never 0. A
24
+ spine with no recorded provider is labelled "not recorded (model X)", never guessed from the
25
+ model name.
26
+ - **`replay-live --live-audit`** (`scripts/replay-live.mjs`) — opt-in: a $0 replay keeps its
27
+ sidecar in a scratch patient dir while it paces, so the panel's live tool-log fallback can be
28
+ seen on screen. Default replay is unchanged.
29
+ - **Panel Providers tab is the keys file (P4b)** — one Settings row per key in
30
+ `~/.config/bareloop/.env` that has a value (the file is created with five empty preset lines,
31
+ `ANTHROPIC_API_KEY` / `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `LOCAL_API_KEY`,
32
+ when missing; an existing file is never edited). Each row has an editable Name (the model id Chat
33
+ uses), API shape (Anthropic / OpenAI-compatible / Gemini), Base URL, a $0 Test at that shape and
34
+ URL, and a total Tokens used; changes save to `config.json` `keys.<ENV NAME>`. A key value of
35
+ `null` counts as set and means "no real key" (local servers); the provider gets a placeholder,
36
+ and local rounds price as an `estimated` guess, never a real $0. Reload keys re-merges the
37
+ keys file onto the shell environment per request, so edits and removals take effect without a
38
+ restart. Chat's Model menu is the Settings
39
+ rows, and one owner (`src/providerrows.js`) resolves model to key + shape + URL for Chat, the
40
+ author doors and the run doors.
41
+ - **Providers tab names a shell-only key** — a preset key variable exported only in the shell
42
+ (not in the keys file) gets no row and no Chat model; the tab now says so under the table, by
43
+ name only (`GET /api/settings/providers` returns `shellOnly: string[]`, never a value).
44
+
45
+ ### Changed
46
+
47
+ - **Panel: no price is displayed** — the Providers table Price column and the Chat job card's
48
+ Token price field are removed. Display only: cost recording, the `estimated` provenance, spend
49
+ sums, caps and the monthly limit are unchanged.
50
+ - The Chat cap row is two equal full-width columns (Cap $ | Time cap) at every width.
51
+ - `config.json` `providers.<row>.key` (P4a key-name dropdown) is replaced by
52
+ `keys.<ENV NAME> = { name, shape, baseUrl }`; the hardcoded `PROVIDER_ROWS` and the panel's fixed
53
+ `MODEL_OPTIONS` are gone.
54
+ - **Monthly limit counts running jobs at their full cap** — a run still in flight (no `job-end`,
55
+ spine written to within the panel's died window) is counted against the limit at its own leg
56
+ cap, not its spend so far, so two runs cannot both start against a limit only one fits. A died
57
+ run still counts its floor. The Money tab's "$ month" stays real spend.
58
+
59
+ ### Fixed
60
+
61
+ - **Monthly limit no longer double-counts a resume** — `run-u --resume` on a leg killed within
62
+ `DIED_MTIME_MS` reserved the old leg at its full cap AND asked for the remainder, so a resume
63
+ that fit was refused; the resumed leg now counts its real spend only
64
+ (`checkMonthlyRoom({ resumingSpine })`).
65
+ - **`POST /api/settings/money` no longer clears the limit on a bad body** — an empty or
66
+ unparseable body, or JSON without the `monthlyLimitUsd` key, is a 400 and leaves `config.json`
67
+ alone; only an explicit `null` or blank clears the limit.
68
+ - **A bad `monthlyLimitUsd` no longer reads as "no limit"** — a `monthlyLimitUsd` present in
69
+ `config.json` but not a number above 0 (`"50"`, `-5`, `0`, `true`, `{}`) now refuses the run
70
+ start and shows as a config problem on the Money tab, like an unparseable file. Absent or
71
+ `null` still means no limit.
72
+ - **Panel author routes: `jobsDir` and body size** — the name-collision check's `jobsDir` test
73
+ seam is now server-side only (it was readable off the client's job card, so any
74
+ `/api/author/start` body could repoint it), and POST bodies over 1 MiB get 413 and the request
75
+ is destroyed (the reader had no cap and ran before the click guard).
76
+ - **Providers Test** — a connection failure now reads "nothing answering at <URL>"; the Test
77
+ cell keeps a fixed width and wraps its result. **Tokens used** counts older model-only spines
78
+ toward the row whose Name exactly equals the model.
79
+
8
80
  ## [0.31.0] — 2026-09-29
9
81
 
10
82
  ### Added
@@ -3302,17 +3302,17 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
3302
3302
  `baselineKind`/`question`: the plain-English question is derived in `src/panel/server.js`
3303
3303
  (`stageQuestionText`) from the signed stage's kind/params only, `null` when no honest
3304
3304
  wording exists (the client then shows the stage's own name). Every
3305
- endpoint is GET/HEAD only (anything else — including every write verb — is `405`); a URL
3305
+ P1 endpoint is GET/HEAD only (anything else — including every write verb — is `405`, outside the
3306
+ two human-click-guarded write families `/api/author/*` and `/api/settings/*` below); a URL
3306
3307
  never joins a path segment into a filesystem read — a runid is looked up in the run list
3307
3308
  first (`RUNID_RE`, `src/panel/server.js` — accepts a `~2`-style backfill-disambiguated
3308
3309
  suffix too, and every endpoint echoes back the LISTED runid, never a filename-derived one
3309
3310
  that can silently drop that suffix), and only the path THAT ROW stores is ever read. A
3310
3311
  taken port fails loudly (names the port, exit `1`) — this command never silently tries a
3311
- different one. No interview, no author, no run trigger, no key/`.env` ever read on this
3312
- path. One more caller of `src/replayio.js`/`src/runlist.js`, in-process, per the layering
3312
+ different one. This read path runs no interview, no author and no run, and reads no key
3313
+ value (the write families below do, server-side, and never return one). One more caller of `src/replayio.js`/`src/runlist.js`, in-process, per the layering
3313
3314
  law (`docs/product/PANEL-BUILD.md` §2) — the panel's HTTP handler holds no flow logic of
3314
- its own. Chat/Settings/authoring (P3/P4) are not built yet; the page says so rather than
3315
- hiding the gap.
3315
+ its own. Chat/authoring (P3) and Settings (P4a/P4b) are built — see below.
3316
3316
 
3317
3317
  - **Panel P2 (live run view, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → the page
3318
3318
  polls, it does not push (no SSE/websocket). One `setInterval` (2s) drives two throttled
@@ -3331,14 +3331,19 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
3331
3331
  `resolveAuditPathForRow` (`src/panel/server.js`), the one shared owner the Run tab's tools
3332
3332
  summary and the Audit tab both read through — tried only while the run's own spine has no
3333
3333
  `job-end` yet, so a finished run never risks reading a stale leftover file at that path.
3334
- `scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N]` is a dev-only, unshipped ($0,
3334
+ `scripts/replay-live.mjs <sourceSpine> <outDir> [--speed N] [--live-audit]` is a dev-only, unshipped ($0,
3335
3335
  no provider call) instrument that paces a real archived spine's records back out under a
3336
3336
  new runid so the panel can be exercised against a "live" run without a paid run — it is not
3337
3337
  a substitute for watching a real run start-to-end, which stays a separately authorized step.
3338
-
3339
- - **Panel P3 (chat/authoring, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → the ONE
3340
- family of write routes the panel serves, all under `/api/author/*` (`src/panel/
3341
- authorroutes.js`); every other route stays GET/HEAD-only per P1's own rule. Every route
3338
+ `--live-audit` (opt-in) puts the sidecar at a scratch patient dir during the replay so the
3339
+ live-fallback path above is exercised, then moves it to the sibling name at the end.
3340
+
3341
+ - **Panel P3 (chat/authoring, `docs/product/PANEL-BUILD.md` Addendum 2026-09-27)** → one of the
3342
+ panel's TWO families of write routes (the other is `/api/settings/*`, see "Settings" below), this
3343
+ one under `/api/author/*` (`src/panel/authorroutes.js`); every other route stays GET/HEAD-only
3344
+ per P1's own rule. A POST body over 1 MiB is refused `413` before it is parsed, and the job
3345
+ card's `jobsDir` (where an existing job name is looked up) is a server-side seam only, never read
3346
+ off the client's card. Every route
3342
3347
  requires the human-click guard: an `x-bareloop-token` header matching a fresh token minted
3343
3348
  once per server start (templated into `index.html` like the port) AND an Origin/Host
3344
3349
  naming this exact `127.0.0.1:<port>` — a request failing either gets `403`, before the
@@ -3368,14 +3373,14 @@ interviews of their own. All three are dispatched by name only: `bareloop run-u
3368
3373
  [`--draft-spend-incomplete`] when the session's drafting spend is > 0 — one cap covers
3369
3374
  drafting + run) detached (array argv,
3370
3375
  never a shell string), with its own log file inside the session's own dir, and the
3371
- server's own environment (a key is never read into or sent to the page — a missing one
3372
- refuses the session at $0, naming only the env var). `src/panel/authorsession.js` takes
3376
+ server's environment with `~/.config/bareloop/.env` merged in per request (a key is never read
3377
+ into or sent to the page — a missing one refuses the session at $0, naming only the env var). `src/panel/authorsession.js` takes
3373
3378
  test-only DI seams (`scout`/`generate`/`confirmGenerate`/`authorFn`/`prepareSigningFn`) so
3374
3379
  a test can drive the real ask()-channel/revise/hash wiring without a live provider call;
3375
3380
  none of them are reachable from `authorroutes.js`'s real construction path. Sessions live
3376
3381
  under `~/.config/bareloop/panel-sessions/<id>/` (each one's own `resolved-spec.json` and
3377
3382
  `signing.json`, the two files `bareloop author` itself already writes). Edit/re-sign, the
3378
- `~/.config/bareloop/.env` keys-file loader, and Settings are P4, not built here.
3383
+ `~/.config/bareloop/.env` keys-file loader, and Settings have landed (P4a/P4b — see "Settings" below).
3379
3384
 
3380
3385
  - **`bareloop run-u <flags…>`** (PANEL-BUILD.md P0 task 2/4) → the person-path run flow
3381
3386
  (the JOBS-table/`--spec` runner, resume, the review door — `docs/logs/FINDINGS.md`'s
@@ -3490,6 +3495,75 @@ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting r
3490
3495
  `runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
3491
3496
  yourself if you want a tarball.
3492
3497
 
3498
+ ### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
3499
+
3500
+ Two files in `~/.config/bareloop/`, outside any repo:
3501
+
3502
+ - **`.env` — key VALUES only.** Plain `NAME=value` lines (optional `export `, `#` comments, one pair
3503
+ of quotes). `loadKeysEnv({ env?, home? })` merges it over the environment; **the shell wins** —
3504
+ the file only fills names the shell leaves unset. Mode other than 600 = a `warning` string
3505
+ (printed on CLI start, shown in Settings), never a refusal. Values go only to provider
3506
+ construction; the page and every log get NAMES and found / not set. `keysForDoor(deps)` is the
3507
+ door helper: an injected `env` with no `keysHome` skips the file entirely (tests never read the
3508
+ real one). The CLI (`run-u`, `interview`, `author`) and the panel both use it.
3509
+ - **`config.json` — every setting, never a key value.** `readConfig({ home })` /
3510
+ `updateConfig(patch, { home })` (atomic tmp + rename, mode 600, unknown fields kept).
3511
+ Fields: `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3512
+ is a `ConfigError`, refused like an unreadable file), `anthropicBalanceNote` (a note the
3513
+ person types; no check ever reads it), `keys.<ENV NAME>` = `{ name, shape, baseUrl }` (the Providers rows' settings). A secret-shaped
3514
+ string anywhere in the document is refused on save. An unreadable file is a `problem` string and
3515
+ a refused run start ($0), never "no limit". There is no monthly TIME limit (dropped).
3516
+
3517
+ **Monthly $ limit.** `checkMonthlyRoom({ capUsd, home, now })` → `{ ok, leftUsd, limitUsd,
3518
+ atLeast }`; `monthlyRefusalText(room)` is the ONE spelling: `Max $<left, 2 decimals> (monthly
3519
+ limit)`. It is called at the run-start seam in `src/userrun.js` `execute` (before any token
3520
+ spends; the CLI prints it to stderr, exit 2) and at the panel's Sign & run (`signRun` in
3521
+ `src/panel/authorroutes.js`, refused server-side whatever the page shows). The page only echoes the
3522
+ same text under `#jf-cap-money` via `GET /api/author/monthly-check?cap=`. "This month" is the local
3523
+ calendar month; a died or incomplete-spend run makes the total an "at least" figure. A run still
3524
+ IN FLIGHT (`job-start`, no `job-end`, spine file written within `DIED_MTIME_MS`, `src/runlist.js`) is
3525
+ counted at its full leg cap (`budgetUsd` less `priorSpentUsd` on its `job-start`), not its spend so
3526
+ far (except the run a resume continues, which counts its real spend), so two runs cannot both start against a limit only one fits; a died run counts its floor.
3527
+ Only the refusal reserves the cap (`monthSpend().reservedUsd`); the Money tab's month figure is
3528
+ real spend.
3529
+
3530
+ **Money & limits tab** (`GET/POST /api/settings/money`): two tiles ($ and tokens, this month / to date, a
3531
+ `≥` prefix when a figure is a floor) and a per-provider table ($, minutes, tokens; a run is grouped
3532
+ by the provider on its `job-start`, an older spine by its model). The limit field auto-saves on
3533
+ change (no Save button); blank clears it. `POST` needs an explicit `monthlyLimitUsd` key (a number
3534
+ above 0, or `null`/blank to clear) — an empty or key-less body is a `400`, never "clear the limit".
3535
+
3536
+ **Providers tab (P4b, `src/providerrows.js`).** One row per key in `.env` that HAS A VALUE — nothing is
3537
+ hardcoded; an empty line is no row, a removed line drops its row on Reload keys. A missing `.env` is
3538
+ created (mode 600) on the first Providers read with five empty preset lines (`ANTHROPIC_API_KEY`,
3539
+ `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `LOCAL_API_KEY`); an existing file is never
3540
+ edited. A value of `null` counts as SET and means "no real key" (the provider is handed the
3541
+ placeholder `no-key-needed`, never the word `null`). Each row: the env NAME (read-only) + found,
3542
+ **Name** (the model id Chat uses), **API shape** (Anthropic / OpenAI-compatible / Gemini), **Base
3543
+ URL** (blank = the shape's own host), **Test**, **Tokens used** (total, display only), and Balance
3544
+ (DeepSeek fetched, Anthropic a typed note). Saves on change to `config.json`
3545
+ `keys.<ENV NAME> = { name, shape, baseUrl }`. Defaults for a key with no saved entry:
3546
+ `ANTHROPIC_API_KEY` claude-sonnet-5 / Anthropic; `DEEPSEEK_API_KEY` deepseek-flash /
3547
+ OpenAI-compatible / `https://api.deepseek.com/v1`; `OPENAI_API_KEY` OpenAI-compatible;
3548
+ `GEMINI_API_KEY` Gemini; `LOCAL_API_KEY` OpenAI-compatible / `http://127.0.0.1:11434/v1` (Ollama, LM
3549
+ Studio, llama.cpp: change the URL); any other name OpenAI-compatible; Name blank until typed. **No
3550
+ price is shown anywhere** (display only — cost recording, the estimated provenance, spend sums, caps
3551
+ and the monthly limit are unchanged; a local model has no known rate, so its rounds come out as a
3552
+ non-zero loud guess, never a real $0).
3553
+
3554
+ `providerrows.js` is the ONE owner of "model → key + shape + URL": `keyRows`, `findRow` (rows on the
3555
+ same shape + endpoint; the Name breaks a tie), `modelChoiceFor` / `chatModels` (Chat's Model menu IS
3556
+ the rows; a blank Name is not offered), `keyNameFor` / `applyConfiguredKey` (a job on `(provider,
3557
+ baseUrl[, model])` reads the matching row's variable, else the provider's built-in one). All doors
3558
+ (`run-u`, `interview`, `author`, the panel's drafting) use it. Routes (all behind the per-start token
3559
+ and Origin check; the chat can never reach them): `GET /api/settings/providers`, `POST .../row`
3560
+ (`{envName, name, shape, baseUrl}`), `POST .../balance-note`, `POST .../test` (one
3561
+ `checkProviderReachable` models-list GET at the row's shape + URL, $0; a refused connection reads
3562
+ `nothing answering at <URL>`), `GET .../balance?env=`
3563
+ (DeepSeek-hosted row, server-side), and `GET /api/author/models` (Chat's Model menu). The old
3564
+ `providers.<row>.key` config field and the key dropdown are gone. Like the authoring routes, a POST
3565
+ body over 1 MiB is refused `413`.
3566
+
3493
3567
  ### The source front door — a plain folder/file/URL, no `--patient` (PRD item 33/M2, `src/source.js`)
3494
3568
 
3495
3569
  A repo job still gives `runJob` a `--patient` checkout it owns. A NON-code job (a resume, a
package/bin/bareloop.mjs CHANGED
@@ -15,7 +15,6 @@ process.stdout.on('error', (e) => {
15
15
  });
16
16
 
17
17
  const code = await main(process.argv.slice(2), {
18
- env: process.env,
19
18
  stdout: process.stdout,
20
19
  stderr: process.stderr,
21
20
  cwd: process.cwd(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",
package/src/authorrun.js CHANGED
@@ -100,6 +100,8 @@
100
100
  // item 34 L17) — `run-interview.mjs` asks for it and writes it
101
101
  // in; a draft missing one, or naming one the provider factory
102
102
  // does not know, dies here loud, listing the known table.
103
+ import { keysForDoor } from './keysfile.js';
104
+ import { applyConfiguredKey, keyNameFor, rowsForHome } from './providerrows.js';
103
105
  import {
104
106
  readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
105
107
  } from 'node:fs';
@@ -141,6 +143,7 @@ class ExitSignal extends Error {
141
143
  /**
142
144
  * @typedef {object} Deps
143
145
  * @property {Record<string,string|undefined>} [env]
146
+ * @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
144
147
  * @property {NodeJS.ReadStream} [stdin]
145
148
  * @property {NodeJS.WritableStream} [stdout]
146
149
  * @property {NodeJS.WritableStream} [stderr]
@@ -162,10 +165,13 @@ class ExitSignal extends Error {
162
165
  * @returns {Promise<number>} an exit code — never calls process.exit
163
166
  */
164
167
  export async function main(argv, deps = {}) {
165
- const env = deps.env ?? process.env;
168
+ // P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
169
+ const keys = keysForDoor(deps);
170
+ const env = keys.env;
166
171
  const stdin = deps.stdin ?? process.stdin;
167
172
  const stdout = deps.stdout ?? process.stdout;
168
173
  const stderr = deps.stderr ?? process.stderr;
174
+ if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
169
175
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
170
176
  const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
171
177
  const invokedAs = deps.invokedAs ?? 'node scripts/run-author.mjs';
@@ -499,8 +505,12 @@ export async function main(argv, deps = {}) {
499
505
 
500
506
  // Secrets load from the environment; they never enter argv (a command line is
501
507
  // world-readable on /proc) and they are never printed.
502
- const AUTHOR_ENV_KEY = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey;
503
- const apiKey = env[AUTHOR_ENV_KEY];
508
+ // P4a item 4 — the key variable the person picked in Settings (config.json) stands in for
509
+ // the provider's built-in one; none picked = the built-in. Names only.
510
+ const keyCfg = rowsForHome(keys.home);
511
+ const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
512
+ const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
513
+ const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
504
514
  if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
505
515
  // F181 — a key that carries a line break/control char/stray whitespace (a
506
516
  // two-line secret-store entry, e.g.) reads as "set" by the presence check
@@ -514,7 +524,7 @@ export async function main(argv, deps = {}) {
514
524
  * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
515
525
  * judge IS the authoring provider this is the key already read above. */
516
526
  const judgeKeyFor = (/** @type {string} */ providerName) => (
517
- env.JUDGE_API_KEY ?? env[resolveProvider(providerName).envKey]
527
+ env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? authorEnv : applyConfiguredKey(env, providerName, undefined, keyCfg))[resolveProvider(providerName).envKey]
518
528
  );
519
529
 
520
530
  /** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
package/src/cli.js CHANGED
@@ -68,6 +68,7 @@ import { panelMain } from './panel/server.js';
68
68
  // (`~/.config/bareloop/runs.jsonl`) and its backfill scan. `bareloop runs`
69
69
  // (list) and `bareloop runs backfill <dir>` (reconstruct rows from archived
70
70
  // spines already on disk) are this rung's only new commands.
71
+ import { keysForDoor } from './keysfile.js';
71
72
  import { appendRun, readRunList, backfillRuns, formatRunRow } from './runlist.js';
72
73
 
73
74
  // The tier->model tables live in `src/providers.js` now (PRD item 28's
@@ -653,11 +654,15 @@ async function runMenu(deps, ctx) {
653
654
  * code, so `bin/bareloop.mjs` can set `process.exitCode` and let node flush
654
655
  * queued stdout on its own (F-something: `process.exit()` can discard it).
655
656
  * @param {string[]} argv
656
- * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string }} deps
657
+ * @param {{ env?: Record<string,string|undefined>, stdout?: any, stderr?: any, cwd?: string, provider?: any, providerFor?: any, judgeProvider?: any, judgeModel?: string|null, now?: () => number, stdin?: any, runlistHome?: string, keysHome?: string }} deps
657
658
  * @returns {Promise<number>}
658
659
  */
659
660
  export async function main(argv, deps = {}) {
660
- const env = deps.env ?? process.env;
661
+ // P4a item 1 — the keys file (`~/.config/bareloop/.env`) fills what the shell leaves
662
+ // unset; the shell wins. An injected `deps.env` skips the file (tests never read the
663
+ // real one) unless `deps.keysHome` names a home.
664
+ const keys = keysForDoor(deps);
665
+ const env = keys.env;
661
666
  const stdout = deps.stdout ?? process.stdout;
662
667
  const stderr = deps.stderr ?? process.stderr;
663
668
  const cwd = deps.cwd ?? process.cwd();
@@ -667,6 +672,7 @@ export async function main(argv, deps = {}) {
667
672
  const ctx = { out, err, cwd, env, now, deps };
668
673
 
669
674
  const [cmd, ...rest] = argv;
675
+ if (keys.warning && cmd && ['run', 'run-u', 'interview', 'author', 'panel'].includes(cmd)) err(`WARNING: ${keys.warning}`);
670
676
  if (!cmd) return runMenu({ ...deps, stdin: deps.stdin ?? process.stdin, stdout }, ctx);
671
677
  if (cmd === 'export') return doExport(rest, ctx);
672
678
  if (cmd === 'run') return doRun(rest, ctx);
@@ -681,7 +687,7 @@ export async function main(argv, deps = {}) {
681
687
  // then `env`/`out`/`err` are overridden to the SAME resolved values every
682
688
  // other command here prints through, so `run-u`'s output lands on the
683
689
  // `stdout`/`stderr` a caller of `main` actually passed.
684
- if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err });
690
+ if (cmd === 'run-u') return runUMain(rest, { ...deps, env, out, err, keysHome: keys.home });
685
691
  // `bareloop interview` — the close-authoring interview (PANEL-BUILD.md P0
686
692
  // task 3/4). This flow reads a TTY (or a piped stdin) directly rather than
687
693
  // through the `out`/`err` line-functions every other command here uses, so
@@ -691,11 +697,11 @@ export async function main(argv, deps = {}) {
691
697
  // person actually typed, never a script path they never invoked and may
692
698
  // not have on disk (a branch-review nit; `scripts/run-interview.mjs` still
693
699
  // supplies none, so it keeps naming itself when run directly).
694
- if (cmd === 'interview') return interviewMain(rest, { ...deps, env, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop interview' });
700
+ if (cmd === 'interview') return interviewMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop interview' });
695
701
  // `bareloop author` — the authoring pipeline (scout, declaration, D9's
696
702
  // gates), same task. Same raw-stream reasoning: its one interactive seam
697
703
  // (the confirm turn) reads stdin directly. Same `invokedAs` reasoning too.
698
- if (cmd === 'author') return authorMain(rest, { ...deps, env, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop author' });
704
+ if (cmd === 'author') return authorMain(rest, { ...deps, env, keysHome: keys.home, stdin: deps.stdin ?? process.stdin, stdout, stderr, invokedAs: 'bareloop author' });
699
705
  // `bareloop replay` — the spine/gate-audit read side (PANEL-BUILD.md P0,
700
706
  // last of the rung's four tasks). Synchronous, file-based, no interactive
701
707
  // seam — same shape as `doHistory`/`doExport`, not the argv-owning
@@ -709,7 +715,7 @@ export async function main(argv, deps = {}) {
709
715
  // Never runs a job, spends money, or reads a key: it serves the run list
710
716
  // and spine/gate-audit reads over `127.0.0.1` only. `deps.runlistHome`
711
717
  // rides through the same injectable seam `run`/`run-u` already use.
712
- if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome });
718
+ if (cmd === 'panel') return panelMain(rest, { out, err, runlistHome: deps.runlistHome, env: deps.env });
713
719
  err(`unknown command ${JSON.stringify(cmd)} — one of: export, run, history, run-u, interview, author, replay, runs, panel`);
714
720
  return 1;
715
721
  }
package/src/config.js ADDED
@@ -0,0 +1,135 @@
1
+ // PANEL-BUILD.md P4a item 2 — the ONE reader/writer of `~/.config/bareloop/config.json`.
2
+ //
3
+ // Every panel/CLI setting lives in this one file (R5): the monthly $ limit, the
4
+ // Anthropic balance note, and the Providers rows' Name / API shape / Base URL, per key variable.
5
+ // Key VALUES never live here — they live in the keys file (`src/keysfile.js`) and the
6
+ // shell env. The write path refuses a document that carries a secret-shaped string
7
+ // (the ONE inventory: `sweepSecretLiterals`, src/validate.js).
8
+ //
9
+ // Shape (unknown fields are kept, never dropped):
10
+ // { "monthlyLimitUsd": <number>, // absent = no limit, no check
11
+ // "anthropicBalanceNote": <number>, // a typed note; no check ever reads it
12
+ // "keys": { "<ENV NAME>": { "name": "<model id>", "shape": "<api shape>", "baseUrl": "<url or blank>" } } }
13
+ //
14
+ // A missing file = defaults. An UNREADABLE file is reported (`problem`), never
15
+ // papered over — the monthly check REFUSES on it rather than silently running with
16
+ // no limit. Written atomically (tmp + rename), mode 600.
17
+ import {
18
+ existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync,
19
+ } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { sweepSecretLiterals } from './validate.js';
23
+
24
+ /** A plain-words config failure (bad value, secret-shaped string, unreadable file). */
25
+ export class ConfigError extends Error {}
26
+
27
+ /**
28
+ * @param {string} [home] test seam; production never passes it
29
+ * @returns {string} `<home>/config.json`
30
+ */
31
+ export function configPath(home) {
32
+ return join(home ?? join(homedir(), '.config', 'bareloop'), 'config.json');
33
+ }
34
+
35
+ /**
36
+ * @typedef {object} ConfigRead
37
+ * @property {Record<string, any>} config the parsed document ({} when missing/unreadable)
38
+ * @property {boolean} exists
39
+ * @property {string|null} problem plain words when the file is there but unreadable
40
+ */
41
+
42
+ /**
43
+ * @param {{ home?: string }} [opts]
44
+ * @returns {ConfigRead}
45
+ */
46
+ export function readConfig(opts = {}) {
47
+ const file = configPath(opts.home);
48
+ if (!existsSync(file)) return { config: {}, exists: false, problem: null };
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
52
+ } catch (/** @type {any} */ e) {
53
+ return { config: {}, exists: true, problem: `${file} is not readable JSON (${e?.code ?? 'parse error'}) — fix or remove it` };
54
+ }
55
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
56
+ return { config: {}, exists: true, problem: `${file} must hold a JSON object — fix or remove it` };
57
+ }
58
+ return { config: parsed, exists: true, problem: null };
59
+ }
60
+
61
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
62
+ /** the API shapes a keys row may name (the `src/providers.js` table names) */
63
+ const SHAPE_IDS = ['anthropic-api', 'openai-api', 'gemini-api'];
64
+
65
+ /**
66
+ * Apply a patch and write. `null` deletes a field. Top-level fields shallow-merge;
67
+ * `keys` merges per key variable name, then per field. Validated before anything is
68
+ * written; nothing is written on a refusal.
69
+ *
70
+ * Patch fields understood: `monthlyLimitUsd` (finite > 0), `anthropicBalanceNote`
71
+ * (finite >= 0), `keys.<ENV NAME>.{name,shape,baseUrl}` (Name = a model id, shape = a provider table name, baseUrl = blank or http(s)). Any other
72
+ * field is passed through untouched (kept), but the whole document is swept for
73
+ * secret-shaped strings first.
74
+ * @param {Record<string, any>} patch
75
+ * @param {{ home?: string }} [opts]
76
+ * @returns {Record<string, any>} the document as written
77
+ */
78
+ export function updateConfig(patch, opts = {}) {
79
+ const cur = readConfig(opts);
80
+ if (cur.problem) throw new ConfigError(cur.problem);
81
+ /** @type {Record<string, any>} */
82
+ const next = { ...cur.config };
83
+ for (const [k, v] of Object.entries(patch)) {
84
+ if (k === 'keys') continue;
85
+ if (v === null) { delete next[k]; continue; }
86
+ if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
87
+ throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
88
+ }
89
+ if (k === 'anthropicBalanceNote' && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
90
+ throw new ConfigError('anthropicBalanceNote must be a number, 0 or more');
91
+ }
92
+ next[k] = v;
93
+ }
94
+ if (patch.keys !== undefined) {
95
+ if (patch.keys === null || typeof patch.keys !== 'object' || Array.isArray(patch.keys)) {
96
+ throw new ConfigError('keys must be an object of key variable names');
97
+ }
98
+ /** @type {Record<string, any>} */
99
+ const keys = { ...(next.keys && typeof next.keys === 'object' ? next.keys : {}) };
100
+ for (const [envName, fields] of Object.entries(patch.keys)) {
101
+ if (!ENV_NAME_RE.test(envName)) throw new ConfigError(`"${envName}" is not a key variable name`);
102
+ if (fields === null) { delete keys[envName]; continue; }
103
+ if (typeof fields !== 'object' || Array.isArray(fields)) throw new ConfigError(`keys.${envName} must be an object`);
104
+ const merged = { ...(keys[envName] && typeof keys[envName] === 'object' ? keys[envName] : {}) };
105
+ for (const [fk, fv] of Object.entries(/** @type {Record<string, any>} */ (fields))) {
106
+ if (fv === null) { delete merged[fk]; continue; }
107
+ if (fk === 'name' && !(typeof fv === 'string' && fv.length <= 100 && !/\s/.test(fv))) {
108
+ throw new ConfigError(`keys.${envName}.name must be a model id (no spaces, up to 100 characters)`);
109
+ }
110
+ if (fk === 'shape' && !SHAPE_IDS.includes(fv)) {
111
+ throw new ConfigError(`keys.${envName}.shape must be one of ${SHAPE_IDS.join(', ')}`);
112
+ }
113
+ if (fk === 'baseUrl' && !(typeof fv === 'string' && (fv === '' || /^https?:\/\/\S+$/.test(fv)))) {
114
+ throw new ConfigError(`keys.${envName}.baseUrl must be blank or an http(s) URL`);
115
+ }
116
+ merged[fk] = fv;
117
+ }
118
+ keys[envName] = merged;
119
+ }
120
+ next.keys = keys;
121
+ }
122
+ /** @type {string[]} */
123
+ const secretPaths = [];
124
+ sweepSecretLiterals(next, (_code, path) => { secretPaths.push(path); });
125
+ if (secretPaths.length) {
126
+ throw new ConfigError(`config.json never holds a key value — a secret-shaped string at ${secretPaths.join(', ')} was refused (keys live in ~/.config/bareloop/.env)`);
127
+ }
128
+ const file = configPath(opts.home);
129
+ mkdirSync(dirname(file), { recursive: true });
130
+ const tmp = `${file}.tmp-${process.pid}`;
131
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
132
+ chmodSync(tmp, 0o600);
133
+ renameSync(tmp, file);
134
+ return next;
135
+ }
@@ -86,6 +86,8 @@
86
86
  // question this used to ask separately is GONE, folded into Destination. A
87
87
  // non-repo source gets the form up to here and then an honest named stop:
88
88
  // bareloop has no checks for that kind of job yet (ruling 7 → M4).
89
+ import { keysForDoor } from './keysfile.js';
90
+ import { applyConfiguredKey, rowsForHome } from './providerrows.js';
89
91
  import {
90
92
  writeFileSync, mkdirSync, existsSync, readFileSync, statSync,
91
93
  } from 'node:fs';
@@ -123,6 +125,7 @@ class ExitSignal extends Error {
123
125
  /**
124
126
  * @typedef {object} Deps
125
127
  * @property {Record<string,string|undefined>} [env]
128
+ * @property {string} [keysHome] P4a — test seam: load this home's `.env` keys file even when `env` is injected.
126
129
  * @property {NodeJS.ReadStream} [stdin]
127
130
  * @property {NodeJS.WritableStream} [stdout]
128
131
  * @property {NodeJS.WritableStream} [stderr]
@@ -146,10 +149,13 @@ class ExitSignal extends Error {
146
149
  * @returns {Promise<number>} an exit code — never calls process.exit
147
150
  */
148
151
  export async function main(argv, deps = {}) {
149
- const env = deps.env ?? process.env;
152
+ // P4a item 1 — the keys file fills what the shell leaves unset (shell wins).
153
+ const keys = keysForDoor(deps);
154
+ const env = keys.env;
150
155
  const stdin = deps.stdin ?? process.stdin;
151
156
  const stdout = deps.stdout ?? process.stdout;
152
157
  const stderr = deps.stderr ?? process.stderr;
158
+ if (keys.warning && deps.env === undefined) stderr.write(`WARNING: ${keys.warning}\n`);
153
159
  const spawnSyncFn = deps.spawnSync ?? realSpawnSync;
154
160
  const invokedAs = deps.invokedAs ?? 'node scripts/run-interview.mjs';
155
161
  const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
@@ -765,7 +771,9 @@ export async function main(argv, deps = {}) {
765
771
  // (a line break/control char/stray whitespace, e.g. a two-line secret-store
766
772
  // entry). A malformed key must not count as KEYED: this offer must read the
767
773
  // same "will it actually run" question run-author itself asks at its door.
768
- const rawKeyValue = providerEnvKey ? env[providerEnvKey] : undefined;
774
+ // P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
775
+ const keyEnv = keys.home !== undefined ? applyConfiguredKey(env, PROVIDER, draft.baseUrl, rowsForHome(keys.home)) : env;
776
+ const rawKeyValue = providerEnvKey ? keyEnv[providerEnvKey] : undefined;
769
777
  const keyProblem = rawKeyValue ? apiKeyProblem(rawKeyValue) : null;
770
778
  const KEYED = providerEnvKey !== null && Boolean(rawKeyValue) && !keyProblem;
771
779
  if (!KEYED) {